Which TLS cipher or certificate negotiation failures must be logged before exit?
**Date of Record:** 2026 October 26 **Scope:** Applies when an agentic session establishes or attempts to establish a TLS/SSL connection to an external endpoint, and the handshake either fails or succeeds with degraded security parameters (weaker cipher, older protocol, or bypassed certificate validation). Does not apply to strictly local Unix domain sockets, unencrypted internal service meshes (mTLS sidecars handle this), or plaintext HTTP connections.
Core Distinction
The separation between **hard handshake failures** (connection aborted due to expired certificates, untrusted CAs, or unsupported protocols) and **silent cryptographic downgrades** (connection succeeds, but the negotiated TLS version or cipher suite falls below the required security baseline). Hard failures are explicit errors; silent downgrades are structural vulnerabilities. Failing to distinguish these creates the **Silent Downgrade Trap**: an instance connects successfully, logs a 200 OK, and terminates. The successor inherits a session that was secretly downgraded to TLS 1.0 with a vulnerable cipher, exposing all subsequent payloads to interception or decryption.
What Was Observed
In secure agent architectures and API integrations, failing to log both hard failures and silent downgrades produces three primary failure modes:
- **The Silent Downgrade:** An adversary or a misconfigured load balancer forces a client to negotiate TLS 1.0 instead of TLS 1.3. The handshake succeeds, but the session is now vulnerable to BEAST or POODLE attacks. The instance logs a successful connection and never records the negotiated protocol.
- **Expired Certificate Bypass:** An instance is configured to `verify=False` or uses a lenient cert store to avoid development friction. It connects to a production endpoint whose certificate expired yesterday. The successor inherits the endpoint as "healthy" and continues to transmit sensitive operational data over an unverified channel.
- **Cipher Suite Dilution:** The endpoint's ECDSA certificate fails to negotiate due to a missing curve, and the connection silently falls back to an RSA key exchange without forward secrecy. The instance logs success, but every future session using this connection is retroactively decryptable if the RSA private key is ever compromised.
Recorded Elements
- **`negotiated_protocol_version`:** The exact TLS/SSL version negotiated (e.g., `TLSv1.3`, `TLSv1.2`), recorded even on successful connections.
- **`negotiated_cipher_suite`:** The exact cipher suite (e.g., `TLS_AES_256_GCM_SHA384` vs. `TLS_RSA_WITH_AES_128_CBC_SHA`), enabling the successor to detect if the connection lost forward secrecy or modern AEAD encryption.
- **`certificate_chain_fingerprint`:** The SHA-256 fingerprint and issuer of the leaf certificate, paired with the `verify_result` (e.g., `VALID`, `EXPIRED`, `SELF_SIGNED`, `UNTRUSTED_CA`).
- **`baseline_violation_directive`:** The fail-closed instruction for the successor if the negotiated parameters fall below the required security baseline (e.g., `HALT_AND_ROTATE_CERTS` or `BLOCK_ENDPOINT_UNTIL_FIXED`).
Condition of Validity
* **Applies when:** The session established a TLS connection to an external endpoint and the negotiated parameters or certificate validation state could affect the confidentiality or integrity of the data exchanged. * **Does not apply when:** The connection is strictly local, handled by a trusted sidecar proxy with its own verified mTLS, or the endpoint is explicitly public and non-sensitive (e.g., a public weather API where downgrade attacks pose no confidentiality risk).
Decision Boundary
- **Was the connection established (even if it later failed at the application layer)?** If no, record standard connection failure. If yes, proceed to inspect the negotiated parameters.
- **Did the negotiation result in a protocol version or cipher suite below the documented baseline (e.g., TLS 1.2+, AEAD ciphers only)?** If yes, the connection is a **Silent Downgrade**. Log the exact parameters and set the `baseline_violation_directive`.
- **Was the certificate chain verified successfully by the client?** If no (e.g., self-signed, expired, or hostname mismatch), log the fingerprint and the failure reason, and treat all data received over this channel as `UNTRUSTED`.
- **Conversely, when is a degraded TLS connection safe to hand off?** Only if the data exchanged was strictly public, non-authenticated, and the successor is explicitly instructed to never reuse the TLS session ticket or connection state for sensitive operations.
Operational Effect
- **Downgrade Detection:** Surfaces silent protocol/cipher downgrades as explicit security boundaries, preventing successors from assuming a connection is secure just because it succeeded.
- **Certificate Integrity:** Ensures that sessions initiated with expired or self-signed certificates do not contaminate the operational memory layer with unverified data.
- **Cryptographic Hygiene:** Preserves the exact cipher suite used, allowing security audits to detect when forward secrecy (PFS) was lost during the session.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json
class TLSVerdict(Enum):
SECURE_NEGOTIATION = auto()
HARD_FAILURE = auto()
SILENT_DOWNGRADE_DETECTED = auto()
CERTIFICATE_UNTRUSTED = auto()
class CertVerifyStatus(Enum):
VALID = auto()
EXPIRED = auto()
SELF_SIGNED = auto()
UNTRUSTED_CA = auto()
HOSTNAME_MISMATCH = auto()
@dataclass(frozen=True)
class HandshakeObservation:
endpoint_id: str
protocol_version: str
cipher_suite: str
cert_fingerprint: str
cert_status: CertVerifyStatus
connection_succeeded: bool
@dataclass(frozen=True)
class SecurityBaseline:
min_protocol_version: str
require_aead_cipher: bool
@dataclass(frozen=True)
class TLSManifest:
verdict: TLSVerdict
violations: tuple[str, ...]
directive: str
manifest_digest: str
class TLSNegotiationGuard:
"""
Evaluates TLS handshake parameters to detect silent downgrades and
certificate validation failures before session exit.
"""
# Order of protocol versions from weakest to strongest
PROTOCOL_ORDER = ("TLSv1.0", "TLSv1.1", "TLSv1.2", "TLSv1.3")
# Simple heuristic for AEAD cipher detection (GCM, CCM, CHACHA20)
AEAD_MARKERS = ("GCM", "CCM", "CHACHA20", "POLY1305")
@staticmethod
def _hash(data: str) -> str:
return hashlib.sha256(data.encode("utf-8")).hexdigest()
@classmethod
def _protocol_rank(cls, version: str) -> int:
try:
return cls.PROTOCOL_ORDER.index(version)
except ValueError:
return -1
@classmethod
def evaluate_handshake(
cls, obs: HandshakeObservation, baseline: SecurityBaseline
) -> TLSManifest:
# Gate 1: Hard failure
if not obs.connection_succeeded:
return TLSManifest(
verdict=TLSVerdict.HARD_FAILURE,
violations=("CONNECTION_ABORTED",),
directive="HALT: TLS handshake failed. Upstream unavailable.",
manifest_digest=cls._hash(f"HARD:{obs.endpoint_id}"),
)
violations: list[str] = []
# Gate 2: Certificate trust verification
if obs.cert_status is not CertVerifyStatus.VALID:
violations.append(f"CERT_{obs.cert_status.name}")
# Gate 3: Protocol version downgrade
obs_rank = cls._protocol_rank(obs.protocol_version)
baseline_rank = cls._protocol_rank(baseline.min_protocol_version)
if obs_rank < baseline_rank:
violations.append(
f"PROTOCOL_DOWNGRADE: {obs.protocol_version} < {baseline.min_protocol_version}"
)
# Gate 4: Cipher suite forward secrecy check
if baseline.require_aead_cipher:
if not any(marker in obs.cipher_suite for marker in cls.AEAD_MARKERS):
violations.append(
f"CIPHER_DEGRADATION: {obs.cipher_suite} lacks AEAD/PFS"
)
# Determine final verdict
if any(v.startswith("CERT_") for v in violations):
verdict = TLSVerdict.CERTIFICATE_UNTRUSTED
directive = "HALT: Do not transmit sensitive data. Cert validation failed."
elif violations:
verdict = TLSVerdict.SILENT_DOWNGRADE_DETECTED
directive = "HALT: Negotiated parameters below baseline. Rotate endpoint or reconfigure TLS."
else:
verdict = TLSVerdict.SECURE_NEGOTIATION
directive = "PROCEED: Connection meets security baseline."
digest_payload = {
"endpoint": obs.endpoint_id,
"verdict": verdict.name,
"protocol": obs.protocol_version,
"cipher": obs.cipher_suite,
"violations": tuple(sorted(violations)),
}
digest = cls._hash(
json.dumps(digest_payload, sort_keys=True, separators=(",", ":"))
)
return TLSManifest(
verdict=verdict,
violations=tuple(violations),
directive=directive,
manifest_digest=digest,
)
@classmethod
def assert_channel_secure(cls, manifest: TLSManifest) -> None:
"""
Fail-closed enforcement: raises if the TLS channel is not secure.
"""
if manifest.verdict is not TLSVerdict.SECURE_NEGOTIATION:
raise RuntimeError(
f"TLS CHANNEL INSECURE: {manifest.verdict.name}. "
f"Violations: {list(manifest.violations)}. {manifest.directive}"
)