From d523c640703cd322f45fb52ce58652b6d2fccfec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:09:57 +0900 Subject: [PATCH 1/8] test(tls): reject polymorphic trust scalar values --- ...test_tls_configuration_scalar_integrity.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/test_tls_configuration_scalar_integrity.py diff --git a/tests/test_tls_configuration_scalar_integrity.py b/tests/test_tls_configuration_scalar_integrity.py new file mode 100644 index 0000000..1c12694 --- /dev/null +++ b/tests/test_tls_configuration_scalar_integrity.py @@ -0,0 +1,99 @@ +"""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 _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] + + +def test_exact_tls_scalar_values_and_standard_paths_remain_supported() -> None: + """Preserve reviewed exact values and ordinary pathlib integration.""" + 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=lambda: "secret", + ) + + 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" From 1442fdb89b8b931992bc92d84f6ae9dcfba43801 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:13:16 +0900 Subject: [PATCH 2/8] fix(tls): seal retained trust scalar values --- src/egressweave/tls.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/egressweave/tls.py b/src/egressweave/tls.py index e242b37..a418613 100644 --- a/src/egressweave/tls.py +++ b/src/egressweave/tls.py @@ -40,33 +40,39 @@ 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: From d2be91562eb2b95ac817f67476f866e5eabe4b26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:16:38 +0900 Subject: [PATCH 3/8] docs(tls): define exact trust scalar boundary --- docs/research/tls-configuration.md | 46 +++++++++++++++++++----------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/research/tls-configuration.md b/docs/research/tls-configuration.md index e9cd100..bb2c9a8 100644 --- a/docs/research/tls-configuration.md +++ b/docs/research/tls-configuration.md @@ -40,15 +40,22 @@ 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. These scalar requirements prevent +subclass-defined text normalization or byte-length 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. 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. 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 +78,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 @@ -113,10 +123,12 @@ 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` or `bytes` subclasses for trust or +identity scalars must migrate to exact built-in values. Standard `pathlib.Path` +objects, exact private trust roots, mutual-TLS identities, deferred private-key +passwords, and the explicit 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 From 00f75595571d8be2445da146d78dd17cffd301f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:38:43 +0900 Subject: [PATCH 4/8] test(tls): reject polymorphic private-key passwords --- ...test_tls_configuration_scalar_integrity.py | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/test_tls_configuration_scalar_integrity.py b/tests/test_tls_configuration_scalar_integrity.py index 1c12694..4f4d123 100644 --- a/tests/test_tls_configuration_scalar_integrity.py +++ b/tests/test_tls_configuration_scalar_integrity.py @@ -26,6 +26,22 @@ def __len__(self) -> int: 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.""" @@ -81,15 +97,48 @@ def test_ca_data_rejects_builtin_subclasses_before_inspection(ca_data: object) - 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=lambda: "secret", + client_private_key_password=password_callback, ) assert configuration.ca_file == "trust/roots.pem" @@ -97,3 +146,4 @@ def test_exact_tls_scalar_values_and_standard_paths_remain_supported() -> None: 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 From d3be1c0bb7ae11d2c8095b7730e5164a4c414920 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:40:35 +0900 Subject: [PATCH 5/8] fix(tls): seal direct private-key password values --- src/egressweave/tls.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/egressweave/tls.py b/src/egressweave/tls.py index a418613..7b3e388 100644 --- a/src/egressweave/tls.py +++ b/src/egressweave/tls.py @@ -76,12 +76,15 @@ def _normalize_ca_data(value: str | bytes | None) -> str | bytes | None: 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) @@ -99,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. """ @@ -141,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", From 6ecc1b2e2caa1af5293a28a5d07a5eddda6f0327 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:43:45 +0900 Subject: [PATCH 6/8] docs(tls): define exact private-key secret values --- docs/research/tls-configuration.md | 45 +++++++++++++++++------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/docs/research/tls-configuration.md b/docs/research/tls-configuration.md index bb2c9a8..5a536f8 100644 --- a/docs/research/tls-configuration.md +++ b/docs/research/tls-configuration.md @@ -44,18 +44,21 @@ 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. These scalar requirements prevent -subclass-defined text normalization or byte-length behavior from participating -in trusted TLS state while deliberately avoiding path expansion, resolution, or -filesystem access during construction. +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. 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. +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 @@ -103,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 @@ -123,12 +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 supplied `str` or `bytes` subclasses for trust or -identity scalars must migrate to exact built-in values. Standard `pathlib.Path` -objects, exact private trust roots, mutual-TLS identities, deferred private-key -passwords, and the explicit TLS 1.2 compatibility floor remain supported. -Applications that previously subclassed `TLSConfiguration` must likewise use an -exact instance with the documented declarative fields. +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 From a63f054afa873981df326515ad5a67d549edaae7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:35:03 -0700 Subject: [PATCH 7/8] docs(tls): record scalar integrity hardening --- CHANGELOG.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfaecb..1bb6e70 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 @@ -395,9 +401,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). protected guard now rejects out-of-bound patch metadata and files, while modified source and tests execute only in an offline, non-root, capability-free, read-only verifier container built from trusted base - dependencies before the patch is applied. The publisher never executes - modified package code before obtaining its external write identity. Workflow - tokens default to read-only and elevate only per job, while CI and autonomous + dependencies before the patch is applied. Workflow tokens default to read-only + and elevate only per job, while CI and autonomous verification install an explicit, SHA-256-locked dependency set. - Make `ValidatedEgressURL` construction factory-only and attach a process-local integrity signature to every issued result. Pinned transports reject forged @@ -429,4 +434,4 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). gap (CWE-350), with redirects and environment proxies disabled. - `EgressNotAllowedError` (a `ValueError` subclass) and `ValidatedEgressURL`. - 35 tests covering URL rejection, address classification, the `allow_local` - container case, DNS-to-private rejection, and transport pinning. + container case, DNS-to-private rejection, and transport pinning. \ No newline at end of file From 0bfc22b48e7e7b13e32d2b3795a17c3c19c8c576 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 08:39:04 -0700 Subject: [PATCH 8/8] docs(tls): preserve existing changelog history --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb6e70..c74b083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -401,8 +401,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). protected guard now rejects out-of-bound patch metadata and files, while modified source and tests execute only in an offline, non-root, capability-free, read-only verifier container built from trusted base - dependencies before the patch is applied. Workflow tokens default to read-only - and elevate only per job, while CI and autonomous + dependencies before the patch is applied. The publisher never executes + modified package code before obtaining its external write identity. Workflow + tokens default to read-only and elevate only per job, while CI and autonomous verification install an explicit, SHA-256-locked dependency set. - Make `ValidatedEgressURL` construction factory-only and attach a process-local integrity signature to every issued result. Pinned transports reject forged @@ -434,4 +435,4 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). gap (CWE-350), with redirects and environment proxies disabled. - `EgressNotAllowedError` (a `ValueError` subclass) and `ValidatedEgressURL`. - 35 tests covering URL rejection, address classification, the `allow_local` - container case, DNS-to-private rejection, and transport pinning. \ No newline at end of file + container case, DNS-to-private rejection, and transport pinning.