Skip to content

TLS endpoints conflate abrupt TCP EOF with authenticated close_notify #173

Description

@kentbull

Problem

ClientTls and RemoterTls wrap sockets without overriding Python's default suppress_ragged_eofs=True. An abrupt TCP EOF can therefore be converted into an ordinary empty TLS read:

data = self.cs.recv(self.bs)
if not data:
    self.cutoff = True

That makes four materially different outcomes difficult or impossible to distinguish:

  • authenticated peer shutdown through TLS close_notify;
  • truncated TLS transport caused by abrupt TCP EOF;
  • retryable nonblocking WANT state; and
  • fatal TLS or socket failure.

The endpoint consequently cannot report whether the protected stream ended authentically, should be retried later, or failed.

Background

TLS 1.2 and TLS 1.3 agree that close_notify authenticates the end of one peer's protected data, but they differ in what the recipient must do next.

TLS 1.2 requires the recipient to send its own close_notify, close immediately, and discard pending writes. TLS 1.3 removes that reciprocal-close requirement: peer close_notify ends the peer's write direction without closing the local write direction.

This issue owns truthful classification of the receive outcome. The negotiated-version policy applied after authenticated closure is tracked by #177.

Python defaults to suppressing ragged EOF for compatibility. With suppression enabled, an unexpected underlying EOF becomes b"" instead of ssl.SSLEOFError. That default is an interoperability choice, not proof that TLS ended cleanly. HIO should expose the distinction first; an application may then explicitly tolerate missing close_notify when its own authenticated framing proves completeness.

Reproduction

  • Establish TLS between a HIO client and server endpoint.
  • Complete the handshake.
  • Close one underlying socket without sending close_notify.
  • Service receives on the peer.
  • Observe that current main reports an ordinary empty read rather than retained TLS truncation.

A deterministic regression can mock SSLContext.wrap_socket(), require suppress_ragged_eofs=False, and make recv() raise SSLEOFError. Equivalent coverage is required for both endpoint roles.

Required behavior

  • SSLWantReadError, SSLWantWriteError, EAGAIN, and EWOULDBLOCK remain retryable and do not change terminal state.
  • SSLZeroReturnError, or an authenticated empty TLS read, records peer close_notify and delegates to negotiated-version policy.
  • Under TLS 1.3, authenticated peer close ends local receive while transmit remains available.
  • Under TLS 1.2, authenticated peer close begins reciprocal shutdown and prevents further application transmission.
  • SSLEOFError reports truncation, retains the original exception, closes both directions, and force-closes the transport.
  • Fatal SSL and connection-wide socket failures likewise retain and surface their cause and force-close.
  • Unsent output remains inspectable on terminal paths.

Proposed direction

Disable ragged-EOF suppression for client and server wrapping:

self.cs = self.context.wrap_socket(
    self.cs,
    do_handshake_on_connect=False,
    suppress_ragged_eofs=False,
    # existing role-specific arguments
)

Classify typed TLS exceptions before generic OSError. Authenticated closure should enter a shared handler whose version-specific behavior is defined by #177; abrupt EOF and fatal transport failures must never pass through that clean-close path.

Security boundary

A ragged EOF is not automatically an exploitable truncation: an application protocol may authenticate message length or otherwise prove that its final record is complete. The transport still must not erase the event. Reporting abrupt EOF as clean TLS shutdown prevents the application from applying its own policy and makes security-sensitive and compatibility-tolerant callers indistinguishable. The default endpoint contract should therefore report the authenticated transport fact without silently choosing application tolerance.

Related work

Acceptance criteria

  • Both TLS endpoint roles disable ragged-EOF suppression.
  • Clean TLS closure, abrupt EOF, WANT conditions, and fatal failures remain distinguishable.
  • Authenticated peer close follows TLS peer-close handling does not follow the negotiated protocol version #177 rather than universally closing only receive.
  • Truncation and fatal failures retain their original exception and force-close.
  • Retryable outcomes preserve endpoint state and queued output.
  • Tests cover each typed outcome for both roles plus abrupt EOF over a real loopback TLS connection.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions