From f11e8d7c69e0c19fad9ecc92296f5fd5ceae9b12 Mon Sep 17 00:00:00 2001 From: Arni Dagur Date: Thu, 16 Jul 2026 17:43:09 +0100 Subject: [PATCH] fix: report EOF without close_notify as UnexpectedEof A transport EOF before the peer's close_notify means the TLS stream was truncated, and a TCP FIN is not authenticated, so an attacker who can inject one could silently cut a stream short. The `ktls` crate's current behaviour returns a clean `Ok(0)`, silently accepting truncation. Rustls however returns `UnexpectedEof`: https://github.com/rustls/rustls/blob/72d0dd69c08700816a3a4f45a2c24fab630c1e07/rustls/src/conn/mod.rs#L506-L510 I chose the error message of `ktls` to match `rustls`. See https://github.com/rustls/rustls/pull/790 --- ktls/src/ktls_stream.rs | 13 +++++++ ktls/tests/integration_test.rs | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ktls/src/ktls_stream.rs b/ktls/src/ktls_stream.rs index 360139b..da45bc2 100644 --- a/ktls/src/ktls_stream.rs +++ b/ktls/src/ktls_stream.rs @@ -109,6 +109,7 @@ where return task::Poll::Ready(Ok(())); } + let filled_before = buf.filled().len(); let read_res = this.inner.as_mut().poll_read(cx, buf); if let task::Poll::Ready(Err(e)) = &read_res { // 5 is a generic "input/output error", it happens when @@ -258,6 +259,18 @@ where } } + if let task::Poll::Ready(Ok(())) = &read_res { + if buf.filled().len() == filled_before { + // A transport EOF before the peer's close_notify means the stream was + // truncated--possibly by an attacker, since a TCP FIN is not + // authenticated. Report it as an error. + return task::Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "peer closed connection without sending TLS close_notify", + ))); + } + } + read_res } } diff --git a/ktls/tests/integration_test.rs b/ktls/tests/integration_test.rs index f6e2800..d39a0bf 100644 --- a/ktls/tests/integration_test.rs +++ b/ktls/tests/integration_test.rs @@ -639,3 +639,65 @@ async fn read_returns_eof_when_close_notify_reply_would_block() { // alert rather than a reset. jh.await.unwrap(); } + +#[tokio::test] +async fn missing_close_notify_is_unexpected_eof() { + let cipher_suite = KtlsCipherSuite { + version: KtlsVersion::TLS13, + typ: KtlsCipherType::AesGcm128, + }; + + let ckey = generate_simple_self_signed(vec!["localhost".to_string()]).unwrap(); + + let mut server_config = + ServerConfig::builder_with_provider(single_suite_provider(cipher_suite)) + .with_protocol_versions(&[cipher_suite.version.as_supported_version()]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![ckey.cert.der().clone()], + rustls::pki_types::PrivatePkcs8KeyDer::from(ckey.key_pair.serialize_der()).into(), + ) + .unwrap(); + server_config.enable_secret_extraction = true; + + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + let ln = TcpListener::bind("[::]:0").await.unwrap(); + let addr = ln.local_addr().unwrap(); + + let mut root_store = RootCertStore::empty(); + root_store.add(ckey.cert.der().clone()).unwrap(); + let client_config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + let tls_connector = TlsConnector::from(Arc::new(client_config)); + + let server = async { + let (stream, _) = ln.accept().await.unwrap(); + let stream = CorkStream::new(stream); + let stream = acceptor.accept(stream).await.unwrap(); + ktls::config_ktls_server(stream).await.unwrap() + }; + let client = async { + let stream = TcpStream::connect(addr).await.unwrap(); + tls_connector + .connect("localhost".try_into().unwrap(), stream) + .await + .unwrap() + }; + let (mut server, mut client) = tokio::join!(server, client); + + // 1. Sanity round trip. + client.write_all(b"hello").await.unwrap(); + client.flush().await.unwrap(); + let mut buf = [0u8; 5]; + server.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"hello"); + + // 2. The client sends a bare TCP FIN, bypassing the TLS shutdown. + client.get_mut().0.shutdown().await.unwrap(); + + // 3. The server must report truncation, not end-of-stream. + let err = server.read(&mut buf).await.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof, "{err}"); +}