Repository: randombit/botan
Pull request #2229 (2019-12-16 11:56:47)

Server Side Asio TLS Stream
This is PR allows wrapping TLS::Server using TLS::Stream.

In addition, the most notable change is the fix for the incorrect handling of stream truncation (aka short-read) errors.

Furthermore,

 * `async_shutdown()` is now available
 * trying to use the stream before performing the handshake fails gracefully
 * the stream can now be used with Beast's `tcp_stream`
 * the names of the error categories (for error codes) have changed

randombit: Closes #2053 

Re testing, the simplest approach may be to implement a fully functioning server using this in the cli, then add some tests of it using Python (as part of `src/scripts/test_cli.py`). Currently in the CLI we have a TLS proxy (using asio), a HTTPS test server (asio) and an echo server (sockets). Possibly one of these could be adapted to using the ASIO TLS interface, but maybe easier to adapt your current test code to add some new functionality. Perhaps combine with Beast and offer a simple static file server over HTTPS?

Re timeouts, I'm not sure if how Botan behaves here interacts well with Beast or asio. But, essentially, timeouts are expected to be handled by the application because the TLS layer is not touching the network. So one strategy might be when starting to connect, set a timer that goes off in X seconds, when it fires check Channel::is_active and if still not active abort the connection.

Sessions being closed without an explicit close notify can be detected by overriding `tls_alert` and monitoring for a close notify, if seen set a boolean to true. When the socket closes, check the flag to see if the session was explicitly closed or was truncated.

hrantzsch: Thanks for the input. Sounds very good--we'll do it like that :)
hrantzsch: Update on timeouts: like Jack said, timeouts should be handled by the application. It turns out that beast, for example, makes this very easy with `beast::tcp_stream::expires_after` ([see here how it's used in the example](https://github.com/randombit/botan/pull/2229/files#diff-cb84b72127de44326c930bb468cfc0cbR329)).

Unfortunately, a `TLS::Stream<beast::tcp_stream>` can currently not be used, because the `TLS::Stream` wants to access `lowest_layer_type`, which is private in the `tcp_stream`. I believe it would be very useful to users if we made our Stream compatible with Beast's `tcp_stream`.
For now, I was able to make it work by simply removing the (internally) unused accessors to `lowest_layer`. However, we might want to find another workaround, since we had the functions in the public API already.

hrantzsch: I replaced the `test_cli.py`-based test by a unit(?) test of a simple client-server communication. The code of the file server lives on [here](https://github.com/hrantzsch/botan-tls-stream-server-example). I would like to link it as an example in the documentation.
randombit: I will do a new review within next few days. CI is failing, looks like a Boost version issue?
hrantzsch: Yeah, looks like beast is not where I thought it was.

We're not quite done, so no hurry with the review.
hrantzsch: What's your opinion on the `lowest_layer` situation? The explanation is a bit longer I'm afraid:

Currently, our Stream offers the type `lowest_layer_type` and the function `lowest_layer()`, which gives you access to the underlying socket, for example to call `connect`. [`asio::ssl::stream`](https://www.boost.org/doc/libs/1_72_0/doc/html/boost_asio/reference/ssl__stream.html) also offers this.

In addition to `lowest_layer`, there's also `next_layer`. Both are the same if we wrap our Stream around a socket (`Botan::TLS::Stream<asio::ip::tcp::socket>`). They are different if, for example, we wrap our Stream around another stream (`Botan::TLS::Stream<beast::tcp_stream>`). In the latter case `lowest_layer` would be the socket but we have the `beast::tcp_stream` in between as the `next_layer`.

Unfortunately, we currently cannot use our Stream with `beast::tcp_stream`, because `beast::tcp_stream::lowest_layer_type` is private and we implemented `TLS::Stream::lowest_layer_type` simply as `next_layer_type::lowest_layer_type`.

I see three options here:
 * keep `Botan::TLS::Stream::lowest_layer()` and don't support `beast::tcp_stream`
    - this is my least favorite option, as I think `tcp_stream` is very nice to use
 * deviate from the interface of `asio::ssl::stream` and change our Stream's interface by removing `lowest_layer`
    - could break compatibility for someone, including our client example in the docs
    - users would probably need to use `beast::get_lowest_layer` to access the Stream's socket instead ([like here](https://github.com/hrantzsch/botan/blob/8dc5e9364305a4788c13dddcd15d2c77c23e5b34/src/tests/test_server_stream.cpp#L153))
 * find a workaround to offer `lowest_layer` without having to access `next_layer_type::lowest_layer_type`
    - this probably means re-implementing `beast::get_lowest_layer`, which recursively asks for `next_layer` until there is none
codecov-io: # [Codecov](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=h1) Report
> Merging [#2229](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=desc) into [master](https://codecov.io/gh/randombit/botan/commit/be9af652680f2ef4dcc00e38b072f5a61f462f3f?src=pr&el=desc) will **increase** coverage by `<.01%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/randombit/botan/pull/2229/graphs/tree.svg?width=650&token=VR6iYmSPH8&height=150&src=pr)](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #2229      +/-   ##
==========================================
+ Coverage   92.51%   92.52%   +<.01%     
==========================================
  Files         555      555              
  Lines       60569    60569              
  Branches     6417     6417              
==========================================
+ Hits        56037    56041       +4     
+ Misses       4532     4528       -4
```


| [Impacted Files](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/bogo\_shim/bogo\_shim.cpp](https://codecov.io/gh/randombit/botan/pull/2229/diff?src=pr&el=tree#diff-c3JjL2JvZ29fc2hpbS9ib2dvX3NoaW0uY3Bw) | `89.12% <0%> (+0.17%)` | :arrow_up: |
| [src/lib/pubkey/mce/mceliece\_key.cpp](https://codecov.io/gh/randombit/botan/pull/2229/diff?src=pr&el=tree#diff-c3JjL2xpYi9wdWJrZXkvbWNlL21jZWxpZWNlX2tleS5jcHA=) | `86.51% <0%> (+1.12%)` | :arrow_up: |
| [src/lib/pubkey/elgamal/elgamal.cpp](https://codecov.io/gh/randombit/botan/pull/2229/diff?src=pr&el=tree#diff-c3JjL2xpYi9wdWJrZXkvZWxnYW1hbC9lbGdhbWFsLmNwcA==) | `97.29% <0%> (+1.35%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=footer). Last update [be9af65...22fa0c8](https://codecov.io/gh/randombit/botan/pull/2229?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

hrantzsch: Review hints for @reneme @tymolc:

The two major issues still to be addressed are the failing tests on Windows and the correct handling SSL short read errors via `map_error_code`.

Tests on Windows pass occasionally, but fail with an `bad_record_mac` error most of the time. There's very strong indication that this is a race in the asynchronous test code.

The desired behavior regarding short read errors is described very well here: https://stackoverflow.com/a/25703699. We need to check that the current strategy of setting `eof` (in sync and async read operations) and mapping it to `StreamTruncated` if required is sound.

A final change I'd like to make is to rename the `test_server_stream` test case more appropriately. Still looking for a good name.
hrantzsch: @randombit I would consider this PR ready for review now.

Enabling the stream to be used as the server-side is actually the smallest part in this PR.

Most of it is concerned with correctly handling stream truncation a.k.a. short-read errors. This is a fix that is probably relevant for the client-side as well.
Before now, we failed to inform users of the stream when an SSL connection was not closed properly. This can facilitate a truncation attack.
Now the stream transforms `EOF` errors to `StreamTruncated` errors when appropriate. Thanks @reneme for the help in sorting out all the cases.

In addition we added an "integration test" instead of the previously discussed test in `test_cli.py`. The test instantiates two streams, one as a client and one as a server, and lets them talk on a local socket.

Minor things remain to be done:

 * [ ] the integration test runs too long on Linux
 * [ ] naming of error categories ([discussion](https://github.com/randombit/botan/pull/2229/files#r374548865))
 * [ ] shutdown_sent is not set correctly in async shutdown ([discussion](https://github.com/randombit/botan/pull/2229/files#r375107425)); `shutdown_sent()` method is also never used
 * [ ] squash commits

reneme: @hrantzsch I hate to say it, but the test we tried to fix today morning, still fails on windows :(

```
tls_stream_integration:
Server ran 52 tests 1 FAILED
Failure 1: Server handshake with error Unexpected error code: stream truncated
Test Close Without Shutdown ran 3 tests all ok
Test Close Without Shutdown Sync ran 2 tests all ok
Test Conversation ran 8 tests all ok
Test Conversation Sync ran 8 tests all ok
Test Eager Close ran 4 tests all ok
Test Eager Close Sync ran 4 tests all ok
Test No Shutdown Response ran 5 tests 2 FAILED
Failure 1: Test No Shutdown Response read gives EOF with error Unexpected error code: bad_record_mac
Failure 2: Test No Shutdown Response received close_notify produced unexpected result '0' expected '1'
Test No Shutdown Response Sync ran 5 tests all ok
```
reneme: @randombit In case you're already awake: It would be cool to kill this (deadlocked) AppVeyor task: https://ci.appveyor.com/project/randombit/botan/builds/31039695
hrantzsch: > Probably we should try to build a simple server based on the TLS server code paths before finishing this up.

I made the Stream use Beast functionality to access its lowest layer. This allows us to run Beast's HTTP server example with our Stream rather than Asio's ssl_stream.
hrantzsch: I decided to change the names of the error categories. I didn't find any hint that they should follow a specific format (as they seemed to do before). [This official example](https://www.boost.org/doc/libs/1_71_0/libs/outcome/doc/html/motivation/plug_error_code2.html) seems to use a free-text name.

I believe the naming is reasonable now. **Note** however, the name of the error categories is public. Theoretically, it was possible for users to
```
if (errorcode.category().name() == "old name") { }
```
Although this is really not a sensible way to do it.
randombit: @hrantzsch :+1: I will review sometime before next Monday.

Patch:
diff --git a/doc/api_ref/tls.rst b/doc/api_ref/tls.rst
index 74eebb0a5..fdffeda26 100644
--- a/doc/api_ref/tls.rst
+++ b/doc/api_ref/tls.rst
@@ -497,6 +497,8 @@ The full code for a TLS client using BSD sockets is in `src/cli/tls_client.cpp`
           }
        }
 
+.. _tls_server:
+
 TLS Servers
 ----------------------------------------
 
@@ -1641,7 +1643,7 @@ Server Code Example
 TLS Stream
 ----------------------------------------
 
-:cpp:class:`TLS::Stream` offers a Boost.Asio compatible wrapper around :cpp:class:`TLS::Client`.
+:cpp:class:`TLS::Stream` offers a Boost.Asio compatible wrapper around :cpp:class:`TLS::Client` and :cpp:class:`TLS::Server`.
 It can be used as an alternative to Boost.Asio's `ssl::stream <https://www.boost.org/doc/libs/1_66_0/doc/html/boost_asio/reference/ssl__stream.html>`_ with minor adjustments to the using code.
 It offers the following interface:
 
@@ -1654,7 +1656,7 @@ It offers the following interface:
                      explicit Stream(Context& context, Args&& ... args)
 
    Construct a new TLS stream.
-   The *context* parameter will be used to set up the underlying *native handle*, i.e. the :ref:`TLS::Client <tls_client>`, when :cpp:func:`handshake` is called.
+   The *context* parameter will be used to initialize the underlying *native handle*, i.e. the :ref:`TLS::Client <tls_client>` or :ref:`TLS::Server <tls_server>`, when :cpp:func:`handshake` is called.
    Using code must ensure the context is kept alive for the lifetime of the stream.
    The further *args* will be forwarded to the *next layer*'s constructor.
 
@@ -1668,7 +1670,6 @@ It offers the following interface:
    .. cpp:function:: void handshake(Connection_Side side, boost::system::error_code& ec)
 
    Set up the *native handle* and perform the TLS handshake.
-   As only the client side of the stream is currently implemented, *side* should be ``Connection_Side::CLIENT``.
 
    .. cpp:function:: void handshake(Connection_Side side)
 
@@ -1690,6 +1691,12 @@ It offers the following interface:
 
    Overload of :cpp:func:`shutdown` that throws an exception if an error occurs.
 
+   .. cpp:function:: template <typename ShutdownHandler> \
+                     void async_shutdown(ShutdownHandler&& handler)
+
+   Asynchronous variant of :cpp:func:`shutdown`.
+   The function returns immediately and calls the *handler* callback function after performing asynchronous I/O to complete the TLS shutdown.
+
 
    .. cpp:function:: template <typename MutableBufferSequence> \
                      std::size_t read_some(const MutableBufferSequence& buffers, boost::system::error_code& ec)
@@ -1734,7 +1741,7 @@ It offers the following interface:
 
 .. cpp:class:: TLS::Context
 
-   A helper class to initialize and configure the Stream's underlying *native handle* (see :cpp:class:`TLS::Client`).
+   A helper class to initialize and configure the Stream's underlying *native handle* (see :cpp:class:`TLS::Client` and :cpp:class:`TLS::Server`).
 
    .. cpp:function:: Context(Credentials_Manager&   credentialsManager, \
                              RandomNumberGenerator& randomNumberGenerator, \
@@ -1750,8 +1757,10 @@ It offers the following interface:
    will cause the stream to override the default implementation of the
    :cpp:func:`tls_verify_cert_chain` callback.
 
-Stream Code Example
-^^^^^^^^^^^^^^^^^^^^
+TLS Stream Client Code Example
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The code below illustrates how to build a simple HTTPS client based on the TLS Stream and Boost.Beast. When run, it fetches the content of `https://botan.randombit.net/news.html` and prints it to stdout.
 
 .. code-block:: cpp
 
diff --git a/src/cli/tls_http_server.cpp b/src/cli/tls_http_server.cpp
index e6eda4577..b84592043 100644
--- a/src/cli/tls_http_server.cpp
+++ b/src/cli/tls_http_server.cpp
@@ -28,6 +28,7 @@
 #include <botan/pkcs8.h>
 #include <botan/version.h>
 #include <botan/hex.h>
+#include <botan/rng.h>
 
 #if defined(BOTAN_HAS_TLS_SQLITE3_SESSION_MANAGER)
    #include <botan/tls_session_manager_sqlite.h>
diff --git a/src/cli/tls_proxy.cpp b/src/cli/tls_proxy.cpp
index 8860458d7..15caa7043 100644
--- a/src/cli/tls_proxy.cpp
+++ b/src/cli/tls_proxy.cpp
@@ -25,6 +25,7 @@
 #include <botan/x509cert.h>
 #include <botan/pkcs8.h>
 #include <botan/hex.h>
+#include <botan/rng.h>
 
 #if defined(BOTAN_HAS_TLS_SQLITE3_SESSION_MANAGER)
    #include <botan/tls_session_manager_sqlite.h>
diff --git a/src/lib/tls/asio/asio_async_ops.h b/src/lib/tls/asio/asio_async_ops.h
index 3520db770..47267a569 100644
--- a/src/lib/tls/asio/asio_async_ops.h
+++ b/src/lib/tls/asio/asio_async_ops.h
@@ -1,7 +1,7 @@
 /*
 * Helpers for TLS ASIO Stream
-* (C) 2018-2019 Jack Lloyd
-*     2018-2019 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
+* (C) 2018-2020 Jack Lloyd
+*     2018-2020 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
 *
 * Botan is released under the Simplified BSD License (see license.txt)
 */
@@ -144,24 +144,18 @@ class AsyncReadOperation : public AsyncBase<Handler, typename Stream::executor_t
                {
                // We have received encrypted data from the network, now hand it to TLS::Channel for decryption.
                boost::asio::const_buffer read_buffer{m_stream.input_buffer().data(), bytes_transferred};
-               try
-                  {
-                  m_stream.native_handle()->received_data(
-                     static_cast<const uint8_t*>(read_buffer.data()), read_buffer.size()
-                  );
-                  }
-               catch(const TLS_Exception& e)
-                  {
-                  ec = e.type();
-                  }
-               catch(const Botan::Exception& e)
-                  {
-                  ec = e.error_type();
-                  }
-               catch(...)
-                  {
-                  ec = Botan::ErrorType::Unknown;
-                  }
+               m_stream.process_encrypted_data(read_buffer, ec);
+               }
+
+            if (m_stream.shutdown_received())
+               {
+               // we just received a 'close_notify' from the peer and don't expect any more data
+               ec = boost::asio::error::eof;
+               }
+            else if (ec == boost::asio::error::eof)
+               {
+               // we did not expect this disconnection from the peer
+               ec = StreamError::StreamTruncated;
                }
 
             if(!m_stream.has_received_data() && !ec && boost::asio::buffer_size(m_buffers) > 0)
@@ -242,6 +236,12 @@ class AsyncWriteOperation : public AsyncBase<Handler, typename Stream::executor_
                return;
                }
 
+            if (ec == boost::asio::error::eof && !m_stream.shutdown_received())
+               {
+               // transport layer was closed by peer without receiving 'close_notify'
+               ec = StreamError::StreamTruncated;
+               }
+
             if(!isContinuation)
                {
                // Make sure the handler is not called without an intermediate initiating function.
@@ -293,28 +293,16 @@ class AsyncHandshakeOperation : public AsyncBase<Handler, typename Stream::execu
          {
          reenter(this)
             {
+            if(ec == boost::asio::error::eof)
+               {
+               ec = StreamError::StreamTruncated;
+               }
+
             if(bytesTransferred > 0 && !ec)
                {
                // Provide encrypted TLS data received from the network to TLS::Channel for decryption
                boost::asio::const_buffer read_buffer {m_stream.input_buffer().data(), bytesTransferred};
-               try
-                  {
-                  m_stream.native_handle()->received_data(
-                     static_cast<const uint8_t*>(read_buffer.data()), read_buffer.size()
-                  );
-                  }
-               catch(const TLS_Exception& e)
-                  {
-                  ec = e.type();
-                  }
-               catch(const Botan::Exception& e)
-                  {
-                  ec = e.error_type();
-                  }
-               catch(...)
-                  {
-                  ec = Botan::ErrorType::Unknown;
-                  }
+               m_stream.process_encrypted_data(read_buffer, ec);
                }
 
             if(m_stream.has_data_to_send() && !ec)
@@ -325,11 +313,10 @@ class AsyncHandshakeOperation : public AsyncBase<Handler, typename Stream::execu
                // operation will eventually call `*this` as its own handler, passing the 0 back to this call operator.
                // This is necessary because the check of `bytesTransferred > 0` assumes that `bytesTransferred` bytes
                // were just read and are available in input_buffer for further processing.
-               AsyncWriteOperation<
-               AsyncHandshakeOperation<typename std::decay<Handler>::type, Stream, Allocator>,
-                                       Stream,
-                                       Allocator>
-                                       op{std::move(*this), m_stream, 0};
+               AsyncWriteOperation<AsyncHandshakeOperation<typename std::decay<Handler>::type, Stream, Allocator>,
+                                   Stream,
+                                   Allocator>
+                                   op{std::move(*this), m_stream, 0};
                return;
                }
 
diff --git a/src/lib/tls/asio/asio_context.h b/src/lib/tls/asio/asio_context.h
index e5e99e83a..e225fde6a 100644
--- a/src/lib/tls/asio/asio_context.h
+++ b/src/lib/tls/asio/asio_context.h
@@ -1,7 +1,7 @@
 /*
  * TLS Context
- * (C) 2018-2019 Jack Lloyd
- *     2018-2019 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
+ * (C) 2018-2020 Jack Lloyd
+ *     2018-2020 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
  *
  * Botan is released under the Simplified BSD License (see license.txt)
  */
diff --git a/src/lib/tls/asio/asio_error.h b/src/lib/tls/asio/asio_error.h
index 7356ab3d7..bdaf8473f 100644
--- a/src/lib/tls/asio/asio_error.h
+++ b/src/lib/tls/asio/asio_error.h
@@ -1,7 +1,7 @@
 /*
 * TLS Stream Errors
-* (C) 2018-2019 Jack Lloyd
-*     2018-2019 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
+* (C) 2018-2020 Jack Lloyd
+*     2018-2020 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
 *
 * Botan is released under the Simplified BSD License (see license.txt)
 */
@@ -20,15 +20,59 @@
 #include <botan/tls_alert.h>
 #include <botan/tls_exceptn.h>
 
+/*
+ * This file defines Botan-specific subclasses of boost::system::error_category.
+ * In addition to the class definition, each category class is accompanied by function `make_error_code` used to create
+ * a `boost::system::error_code` of the category from some other kind of error in Botan (for example, a TLS alert).
+ * Since error_category instances should be singletons, there's also a method to get/create the instance for each class.
+ */
+
 namespace Botan {
 namespace TLS {
 
+enum StreamError
+   {
+   StreamTruncated = 1
+   };
+
+//! @brief An error category for errors from the TLS::Stream
+struct StreamCategory : public boost::system::error_category
+   {
+   public:
+      const char* name() const noexcept override
+         {
+         return "Botan TLS Stream";
+         }
+
+      std::string message(int value) const override
+         {
+         switch(value)
+            {
+            case StreamTruncated:
+               return "stream truncated";
+            default:
+               return "generic error";
+            }
+         }
+   };
+
+inline const StreamCategory& botan_stream_category()
+   {
+   static StreamCategory category;
+   return category;
+   }
+
+inline boost::system::error_code make_error_code(Botan::TLS::StreamError e)
+   {
+   return boost::system::error_code(static_cast<int>(e), Botan::TLS::botan_stream_category());
+   }
+
 //! @brief An error category for TLS alerts
 struct BotanAlertCategory : boost::system::error_category
    {
    const char* name() const noexcept override
       {
-      return "asio.botan.tls.alert";
+      return "Botan TLS Alert";
       }
 
    std::string message(int ev) const override
@@ -56,7 +100,7 @@ struct BotanErrorCategory : boost::system::error_category
    {
    const char* name() const noexcept override
       {
-      return "asio.botan.tls";
+      return "Botan";
       }
 
    std::string message(int ev) const override
@@ -78,6 +122,10 @@ inline boost::system::error_code make_error_code(Botan::ErrorType e)
 
 }  // namespace Botan
 
+ /*
+ * Add a template specialization of `is_error_code_enum` for each kind of error to allow automatic conversion to an
+ * error code.
+ */
 namespace boost {
 namespace system {
 
@@ -86,6 +134,11 @@ template<> struct is_error_code_enum<Botan::TLS::Alert::Type>
    static const bool value = true;
    };
 
+template<> struct is_error_code_enum<Botan::TLS::StreamError>
+   {
+   static const bool value = true;
+   };
+
 template<> struct is_error_code_enum<Botan::ErrorType>
    {
    static const bool value = true;
diff --git a/src/lib/tls/asio/asio_stream.h b/src/lib/tls/asio/asio_stream.h
index 4d2de0967..24286f10d 100644
--- a/src/lib/tls/asio/asio_stream.h
+++ b/src/lib/tls/asio/asio_stream.h
@@ -1,7 +1,7 @@
 /*
 * TLS ASIO Stream
-* (C) 2018-2019 Jack Lloyd
-*     2018-2019 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
+* (C) 2018-2020 Jack Lloyd
+*     2018-2020 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
 *
 * Botan is released under the Simplified BSD License (see license.txt)
 */
@@ -23,12 +23,13 @@
 #include <botan/tls_channel.h>
 #include <botan/tls_client.h>
 #include <botan/tls_magic.h>
+#include <botan/tls_server.h>
 
 // We need to define BOOST_ASIO_DISABLE_SERIAL_PORT before any asio imports. Otherwise asio will include <termios.h>,
 // which interferes with Botan's amalgamation by defining macros like 'B0' and 'FF1'.
 #define BOOST_ASIO_DISABLE_SERIAL_PORT
 #include <boost/asio.hpp>
-#include <boost/beast/core/flat_buffer.hpp>
+#include <boost/beast/core.hpp>
 
 #include <algorithm>
 #include <memory>
@@ -40,8 +41,6 @@ namespace TLS {
 /**
  * @brief boost::asio compatible SSL/TLS stream
  *
- * Currently only the TLS::Client specialization is implemented.
- *
  * @tparam StreamLayer type of the next layer, usually a network socket
  * @tparam ChannelT type of the native_handle, defaults to Botan::TLS::Channel, only needed for testing purposes
  */
@@ -64,7 +63,8 @@ class Stream
       explicit Stream(Context& context, Args&& ... args)
          : m_context(context)
          , m_nextLayer(std::forward<Args>(args)...)
-         , m_core(m_receive_buffer, m_send_buffer, m_context)
+         , m_core(*this)
+         , m_shutdown_received(false)
          , m_input_buffer_space(MAX_CIPHERTEXT_SIZE, '\0')
          , m_input_buffer(m_input_buffer_space.data(), m_input_buffer_space.size())
          {}
@@ -83,7 +83,8 @@ class Stream
       explicit Stream(Arg&& arg, Context& context)
          : m_context(context)
          , m_nextLayer(std::forward<Arg>(arg))
-         , m_core(m_receive_buffer, m_send_buffer, m_context)
+         , m_core(*this)
+         , m_shutdown_received(false)
          , m_input_buffer_space(MAX_CIPHERTEXT_SIZE, '\0')
          , m_input_buffer(m_input_buffer_space.data(), m_input_buffer_space.size())
          {}
@@ -101,19 +102,37 @@ class Stream
       //! @{
 
       using next_layer_type = typename std::remove_reference<StreamLayer>::type;
-      using lowest_layer_type = typename next_layer_type::lowest_layer_type;
-      using executor_type = typename next_layer_type::executor_type;
-      using native_handle_type = typename std::add_pointer<ChannelT>::type;
-
-      executor_type get_executor() noexcept { return m_nextLayer.get_executor(); }
 
       const next_layer_type& next_layer() const { return m_nextLayer; }
       next_layer_type& next_layer() { return m_nextLayer; }
 
+#if BOOST_VERSION >= 107000
+      /*
+       * From Boost 1.70 onwards Beast types no longer provide public access to the member function `lowest_layer()`.
+       * Instead, the new free-standing functions in Beast need to be used.
+       * See also: https://github.com/boostorg/beast/commit/6a658b5c3a36f8d58334f8b6582c01c3e87768ae
+       */
+      using lowest_layer_type = typename boost::beast::lowest_layer_type<StreamLayer>;
+
+      lowest_layer_type& lowest_layer() { return boost::beast::get_lowest_layer(m_nextLayer); }
+      const lowest_layer_type& lowest_layer() const { return boost::beast::get_lowest_layer(m_nextLayer); }
+#else
+      using lowest_layer_type = typename next_layer_type::lowest_layer_type;
+
       lowest_layer_type& lowest_layer() { return m_nextLayer.lowest_layer(); }
       const lowest_layer_type& lowest_layer() const { return m_nextLayer.lowest_layer(); }
+#endif
 
-      native_handle_type native_handle() { return m_native_handle.get(); }
+      using executor_type = typename next_layer_type::executor_type;
+      executor_type get_executor() noexcept { return m_nextLayer.get_executor(); }
+
+      using native_handle_type = typename std::add_pointer<ChannelT>::type;
+      native_handle_type native_handle()
+         {
+         if(m_native_handle == nullptr)
+            { throw Invalid_State("Invalid handshake state"); }
+         return m_native_handle.get();
+         }
 
       //! @}
       //! \name configuration and callback setters
@@ -193,7 +212,7 @@ class Stream
        * The function call will block until handshaking is complete or an error occurs.
        *
        * @param side The type of handshaking to be performed, i.e. as a client or as a server.
-       * @throws boost::system::system_error if error occured, or if the chosen Connection_Side is not available
+       * @throws boost::system::system_error if error occured
        */
       void handshake(Connection_Side side)
          {
@@ -214,8 +233,11 @@ class Stream
          {
          setup_native_handle(side, ec);
 
-         // send client hello, which was written to the send buffer on client instantiation
-         send_pending_encrypted_data(ec);
+         if(side == CLIENT)
+            {
+            // send client hello, which was written to the send buffer on client instantiation
+            send_pending_encrypted_data(ec);
+            }
 
          while(!native_handle()->is_active() && !ec)
             {
@@ -223,22 +245,7 @@ class Stream
             if(ec)
                { return; }
 
-            try
-               {
-               native_handle()->received_data(static_cast<const uint8_t*>(read_buffer.data()), read_buffer.size());
-               }
-            catch(const TLS_Exception& e)
-               {
-               ec = e.type();
-               }
-            catch(const Botan::Exception& e)
-               {
-               ec = e.error_type();
-               }
-            catch(const std::exception&)
-               {
-               ec = Botan::ErrorType::Unknown;
-               }
+            process_encrypted_data(read_buffer, ec);
 
             send_pending_encrypted_data(ec);
             }
@@ -252,11 +259,10 @@ class Stream
        * @param side The type of handshaking to be performed, i.e. as a client or as a server.
        * @param handler The handler to be called when the handshake operation completes.
        *                The equivalent function signature of the handler must be: void(boost::system::error_code)
-       * @throws NotImplemented if Connection_Side is not CLIENT
        */
       template <typename HandshakeHandler>
       auto async_handshake(Connection_Side side, HandshakeHandler&& handler) ->
-         BOOST_ASIO_INITFN_RESULT_TYPE(HandshakeHandler, void(boost::system::error_code))
+      BOOST_ASIO_INITFN_RESULT_TYPE(HandshakeHandler, void(boost::system::error_code))
          {
          BOOST_ASIO_HANDSHAKE_HANDLER_CHECK(HandshakeHandler, handler) type_check;
 
@@ -295,29 +301,18 @@ class Stream
        * This function is used to shut down SSL on the stream. The function call will block until SSL has been shut down
        * or an error occurs. Note that this will not close the lowest layer.
        *
+       * Note that this can be used in reaction of a received shutdown alert from the peer.
+       *
        * @param ec Set to indicate what error occured, if any.
        */
       void shutdown(boost::system::error_code& ec)
          {
-         try
+         try_with_error_code([&]
             {
             native_handle()->close();
-            }
-         catch(const TLS_Exception& e)
-            {
-            ec = e.type();
-            }
-         catch(const Botan::Exception& e)
-            {
-            ec = e.error_type();
-            }
-         catch(const std::exception&)
-            {
-            ec = Botan::ErrorType::Unknown;
-            }
+            }, ec);
 
-         if(!ec)
-            { send_pending_encrypted_data(ec); }
+         send_pending_encrypted_data(ec);
          }
 
       /**
@@ -326,6 +321,8 @@ class Stream
        * This function is used to shut down SSL on the stream. The function call will block until SSL has been shut down
        * or an error occurs. Note that this will not close the lowest layer.
        *
+       * Note that this can be used in reaction of a received shutdown alert from the peer.
+       *
        * @throws boost::system::system_error if error occured
        */
       void shutdown()
@@ -335,22 +332,66 @@ class Stream
          boost::asio::detail::throw_error(ec, "shutdown");
          }
 
+   private:
+      /**
+       * @brief Internal wrapper type to adapt the expected signature of `async_shutdown`
+       *        to the completion handler signature of `AsyncWriteOperation`.
+       *
+       * This is boilerplate to ignore the `size_t` parameter that is passed to the
+       * completion handler of `AsyncWriteOperation`.
+       *
+       * @todo in C++14 and above this could be implemented as a mutable lambda expression
+       *       that captures `handler` by perfect forwarding, like so:
+       *
+       *       [h = std::forward<Handler>(handler)] (...) mutable { return h(ec); }
+       */
+      template <typename Handler>
+      class Wrapper
+         {
+         public:
+            Wrapper(Handler&& handler) : _handler(std::forward<Handler>(handler)) {}
+            void operator()(boost::system::error_code ec, size_t)
+               {
+               _handler(ec);
+               }
+         private:
+            Handler _handler;
+         };
+
+   public:
       /**
        * @brief Asynchronously shut down SSL on the stream.
        *
        * This function call always returns immediately.
        *
-       * @param handler The handler to be called when the handshake operation completes.
+       * Note that this can be used in reaction of a received shutdown alert from the peer.
+       *
+       * @param handler The handler to be called when the shutdown operation completes.
        *                The equivalent function signature of the handler must be: void(boost::system::error_code)
        */
       template <typename ShutdownHandler>
       void async_shutdown(ShutdownHandler&& handler)
          {
-         BOOST_ASIO_HANDSHAKE_HANDLER_CHECK(ShutdownHandler, handler) type_check;
-         BOTAN_UNUSED(handler);
-         throw Not_Implemented("async shutdown is not implemented");
-         // TODO: Implement a subclass of AsyncBase that calls native_handle()->close() and writes pending data from
-         // the core to the network, e.g. using AsyncWriteOperation.
+         boost::system::error_code ec;
+         try_with_error_code([&]
+            {
+            native_handle()->close();
+            }, ec);
+         // If ec is set by native_handle->close(), the AsyncWriteOperation created below will do nothing but call the
+         // handler with the error_code set appropriately - no need to early return here.
+
+         using ShutdownHandlerWrapper = Wrapper<ShutdownHandler>;
+
+         ShutdownHandlerWrapper w(std::forward<ShutdownHandler>(handler));
+         BOOST_ASIO_SHUTDOWN_HANDLER_CHECK(ShutdownHandler, w) type_check;
+
+         boost::asio::async_completion<ShutdownHandlerWrapper, void(boost::system::error_code, std::size_t)>
+         init(w);
+
+         detail::AsyncWriteOperation<typename std::decay<ShutdownHandlerWrapper>::type, Stream>
+         op{std::move(init.completion_handler), *this, boost::asio::buffer_size(send_buffer())};
+
+         return init.result.get();
          }
 
       //! @}
@@ -364,7 +405,8 @@ class Stream
        * occurs.
        *
        * @param buffers The buffers into which the data will be read.
-       * @param ec Set to indicate what error occured, if any.
+       * @param ec Set to indicate what error occurred, if any. Specifically, StreamTruncated will be set if the peer
+       *           has closed the connection but did not properly shut down the SSL connection.
        * @return The number of bytes read. Returns 0 if an error occurred.
        */
       template <typename MutableBufferSequence>
@@ -378,21 +420,20 @@ class Stream
          if(ec)
             { return 0; }
 
-         try
-            {
-            native_handle()->received_data(static_cast<const uint8_t*>(read_buffer.data()), read_buffer.size());
-            }
-         catch(const TLS_Exception& e)
-            {
-            ec = e.type();
-            }
-         catch(const Botan::Exception& e)
+         process_encrypted_data(read_buffer, ec);
+
+         if(ec)  // something went wrong in process_encrypted_data()
+            { return 0; }
+
+         if(shutdown_received())
             {
-            ec = e.error_type();
+            // we just received a 'close_notify' from the peer and don't expect any more data
+            ec = boost::asio::error::eof;
             }
-         catch(const std::exception&)
+         else if(ec == boost::asio::error::eof)
             {
-            ec = Botan::ErrorType::Unknown;
+            // we did not expect this disconnection from the peer
+            ec = StreamError::StreamTruncated;
             }
 
          return !ec ? copy_received_data(buffers) : 0;
@@ -465,8 +506,8 @@ class Stream
        */
       template <typename ConstBufferSequence, typename WriteHandler>
       auto async_write_some(const ConstBufferSequence& buffers, WriteHandler&& handler) ->
-         BOOST_ASIO_INITFN_RESULT_TYPE(WriteHandler,
-                                       void(boost::system::error_code, std::size_t))
+      BOOST_ASIO_INITFN_RESULT_TYPE(WriteHandler,
+                                    void(boost::system::error_code, std::size_t))
          {
          BOOST_ASIO_WRITE_HANDLER_CHECK(WriteHandler, handler) type_check;
 
@@ -502,8 +543,8 @@ class Stream
        */
       template <typename MutableBufferSequence, typename ReadHandler>
       auto async_read_some(const MutableBufferSequence& buffers, ReadHandler&& handler) ->
-         BOOST_ASIO_INITFN_RESULT_TYPE(ReadHandler,
-                                       void(boost::system::error_code, std::size_t))
+      BOOST_ASIO_INITFN_RESULT_TYPE(ReadHandler,
+                                    void(boost::system::error_code, std::size_t))
          {
          BOOST_ASIO_READ_HANDLER_CHECK(ReadHandler, handler) type_check;
 
@@ -516,6 +557,12 @@ class Stream
 
       //! @}
 
+      //! @brief Indicates whether a close_notify alert has been received from the peer.
+      bool shutdown_received() const
+         {
+         return m_shutdown_received;
+         }
+
    protected:
       template <class H, class S, class M, class A> friend class detail::AsyncReadOperation;
       template <class H, class S, class A> friend class detail::AsyncWriteOperation;
@@ -533,28 +580,32 @@ class Stream
       class StreamCore : public Botan::TLS::Callbacks
          {
          public:
-            StreamCore(boost::beast::flat_buffer& receive_buffer, boost::beast::flat_buffer& send_buffer, Context& context)
-               : m_receive_buffer(receive_buffer), m_send_buffer(send_buffer), m_tls_context(context) {}
+            StreamCore(Stream& stream) : m_stream(stream) {}
 
             virtual ~StreamCore() = default;
 
             void tls_emit_data(const uint8_t data[], std::size_t size) override
                {
-               m_send_buffer.commit(
-                  boost::asio::buffer_copy(m_send_buffer.prepare(size), boost::asio::buffer(data, size))
+               m_stream.m_send_buffer.commit(
+                  boost::asio::buffer_copy(m_stream.m_send_buffer.prepare(size), boost::asio::buffer(data, size))
                );
                }
 
             void tls_record_received(uint64_t, const uint8_t data[], std::size_t size) override
                {
-               m_receive_buffer.commit(
-                  boost::asio::buffer_copy(m_receive_buffer.prepare(size), boost::asio::const_buffer(data, size))
+               m_stream.m_receive_buffer.commit(
+                  boost::asio::buffer_copy(m_stream.m_receive_buffer.prepare(size), boost::asio::const_buffer(data, size))
                );
                }
 
             void tls_alert(Botan::TLS::Alert alert) override
                {
-               BOTAN_UNUSED(alert);
+               if(alert.type() == Botan::TLS::Alert::CLOSE_NOTIFY)
+                  {
+                  m_stream.set_shutdown_received();
+                  // Channel::process_alert will automatically write the corresponding close_notify response to the
+                  // send_buffer and close the native_handle after this function returns.
+                  }
                }
 
             std::chrono::milliseconds tls_verify_cert_chain_ocsp_timeout() const override
@@ -576,9 +627,9 @@ class Stream
                const std::string& hostname,
                const TLS::Policy& policy) override
                {
-               if(m_tls_context.has_verify_callback())
+               if(m_stream.m_context.has_verify_callback())
                   {
-                  m_tls_context.get_verify_callback()(cert_chain, ocsp_responses, trusted_roots, usage, hostname, policy);
+                  m_stream.m_context.get_verify_callback()(cert_chain, ocsp_responses, trusted_roots, usage, hostname, policy);
                   }
                else
                   {
@@ -586,9 +637,8 @@ class Stream
                   }
                }
 
-            boost::beast::flat_buffer& m_receive_buffer;
-            boost::beast::flat_buffer& m_send_buffer;
-            Context& m_tls_context;
+         private:
+            Stream& m_stream;
          };
 
       const boost::asio::mutable_buffer& input_buffer() { return m_input_buffer; }
@@ -631,30 +681,46 @@ class Stream
        * Botan::TLS::Server.
        *
        * @param side The desired connection side (client or server)
-       * @param ec Set to NotImplemented when side is SERVER - currently only CLIENT is implemented
+       * @param ec Set to indicate what error occurred, if any.
        */
       template<class T = ChannelT>
       typename std::enable_if<std::is_same<Channel, T>::value>::type
       setup_native_handle(Connection_Side side, boost::system::error_code& ec)
          {
-         if(side == CLIENT)
+         try_with_error_code([&]
             {
-            m_native_handle = std::unique_ptr<Client>(
-                                 new Client(m_core,
-                                            m_context.m_session_manager,
-                                            m_context.m_credentials_manager,
-                                            m_context.m_policy,
-                                            m_context.m_rng,
-                                            m_context.m_server_info));
-            }
-         else
-            {
-            // TODO: First steps in order to support the server side of this stream would be to instantiate a
-            // Botan::TLS::Server instance as the stream's native_handle and implement the handshake appropriately.
-            ec = Botan::ErrorType::NotImplemented;
-            }
+            if(side == CLIENT)
+               {
+               m_native_handle = std::unique_ptr<Client>(
+                  new Client(m_core,
+                             m_context.m_session_manager,
+                             m_context.m_credentials_manager,
+                             m_context.m_policy,
+                             m_context.m_rng,
+                             m_context.m_server_info,
+                             Protocol_Version::latest_tls_version()));
+               }
+            else
+               {
+               m_native_handle = std::unique_ptr<Server>(
+                  new Server(m_core,
+                             m_context.m_session_manager,
+                             m_context.m_credentials_manager,
+                             m_context.m_policy,
+                             m_context.m_rng,
+                             false /* no DTLS */));
+               }
+            }, ec);
          }
 
+      /** @brief Synchronously write encrypted data from the send buffer to the next layer.
+       *
+       * If this function is called with an error code other than 'Success', it will do nothing and return 0.
+       *
+       * @param ec Set to indicate what error occurred, if any. Specifically, StreamTruncated will be set if the peer
+       *           has closed the connection but did not properly shut down the SSL connection.
+       * @return The number of bytes written.
+       */
       size_t send_pending_encrypted_data(boost::system::error_code& ec)
          {
          if(ec)
@@ -662,9 +728,21 @@ class Stream
 
          auto writtenBytes = boost::asio::write(m_nextLayer, send_buffer(), ec);
          consume_send_buffer(writtenBytes);
+
+         if(ec == boost::asio::error::eof && !shutdown_received())
+            {
+            // transport layer was closed by peer without receiving 'close_notify'
+            ec = StreamError::StreamTruncated;
+            }
+
          return writtenBytes;
          }
 
+      /**
+       * @brief Pass plaintext data to the native handle for processing.
+       *
+       * The native handle will then create TLS records and hand them back to the Stream via the tls_emit_data callback.
+       */
       template <typename ConstBufferSequence>
       void tls_encrypt(const ConstBufferSequence& buffers, boost::system::error_code& ec)
          {
@@ -676,25 +754,56 @@ class Stream
                it++)
             {
             const boost::asio::const_buffer buffer = *it;
-            try
+            try_with_error_code([&]
                {
                native_handle()->send(static_cast<const uint8_t*>(buffer.data()), buffer.size());
-               }
-            catch(const TLS_Exception& e)
-               {
-               ec = e.type();
-               }
-            catch(const Botan::Exception& e)
-               {
-               ec = e.error_type();
-               }
-            catch(const std::exception&)
-               {
-               ec = Botan::ErrorType::Unknown;
-               }
+               }, ec);
+            }
+         }
+
+      /**
+       * @brief Pass encrypted data to the native handle for processing.
+       *
+       * If an exception occurs while processing the data, an error code will be set.
+       *
+       * @param read_buffer Input buffer containing the encrypted data.
+       * @param ec Set to indicate what error occurred, if any.
+       */
+      void process_encrypted_data(const boost::asio::const_buffer& read_buffer, boost::system::error_code& ec)
+         {
+         try_with_error_code([&]
+            {
+            native_handle()->received_data(static_cast<const uint8_t*>(read_buffer.data()), read_buffer.size());
+            }, ec);
+         }
+
+      //! @brief Catch exceptions and set an error_code
+      template <typename Fun>
+      void try_with_error_code(Fun f, boost::system::error_code& ec)
+         {
+         try
+            {
+            f();
+            }
+         catch(const TLS_Exception& e)
+            {
+            ec = e.type();
+            }
+         catch(const Botan::Exception& e)
+            {
+            ec = e.error_type();
+            }
+         catch(const std::exception&)
+            {
+            ec = Botan::ErrorType::Unknown;
             }
          }
 
+      void set_shutdown_received()
+         {
+         m_shutdown_received = true;
+         }
+
       Context&                  m_context;
       StreamLayer               m_nextLayer;
 
@@ -704,6 +813,8 @@ class Stream
       StreamCore                m_core;
       std::unique_ptr<ChannelT> m_native_handle;
 
+      bool m_shutdown_received;
+
       // Buffer space used to read input intended for the core
       std::vector<uint8_t>              m_input_buffer_space;
       const boost::asio::mutable_buffer m_input_buffer;


Test patch:
diff --git a/src/tests/test_tls_stream_integration.cpp b/src/tests/test_tls_stream_integration.cpp
new file mode 100644
index 000000000..734840721
--- /dev/null
+++ b/src/tests/test_tls_stream_integration.cpp
@@ -0,0 +1,724 @@
+/*
+* TLS ASIO Stream Client-Server Interaction Test
+* (C) 2018-2020 Jack Lloyd
+*     2018-2020 Hannes Rantzsch, Rene Meusel
+*
+* Botan is released under the Simplified BSD License (see license.txt)
+*/
+
+#include "tests.h"
+
+#if defined(BOTAN_HAS_TLS) && defined(BOTAN_HAS_TLS_ASIO_STREAM) && defined(BOTAN_TARGET_OS_HAS_THREADS)
+
+// first version to be compatible with Networking TS (N4656) and boost::beast
+#include <boost/version.hpp>
+#if BOOST_VERSION >= 106600
+
+#include <functional>
+
+#include <botan/asio_stream.h>
+#include <botan/auto_rng.h>
+
+#include <boost/asio.hpp>
+
+#include "../cli/tls_helpers.h"  // for Basic_Credentials_Manager
+
+namespace {
+
+namespace net = boost::asio;
+
+using tcp = net::ip::tcp;
+using error_code = boost::system::error_code;
+using ssl_stream = Botan::TLS::Stream<net::ip::tcp::socket>;
+using namespace std::placeholders;
+using Result = Botan_Tests::Test::Result;
+
+static const auto k_timeout = std::chrono::seconds(3);
+static const auto k_endpoints = std::vector<tcp::endpoint> {tcp::endpoint{net::ip::make_address("127.0.0.1"), 8082}};
+
+enum { max_msg_length = 512 };
+
+static std::string server_cert() { return Botan_Tests::Test::data_dir() + "/x509/certstor/cert1.crt"; }
+static std::string server_key() { return Botan_Tests::Test::data_dir() + "/x509/certstor/key01.pem"; }
+
+class Timeout_Exception : public std::runtime_error
+   {
+      using std::runtime_error::runtime_error;
+   };
+
+class Side
+   {
+   public:
+      Side()
+         : m_credentials_manager(true, ""),
+           m_ctx(m_credentials_manager, m_rng, m_session_mgr, m_policy, Botan::TLS::Server_Information()) {}
+
+      Side(const std::string& server_cert, const std::string& server_key)
+         : m_credentials_manager(m_rng, server_cert, server_key),
+           m_ctx(m_credentials_manager, m_rng, m_session_mgr, m_policy, Botan::TLS::Server_Information()) {}
+
+      virtual ~Side() {}
+
+      net::mutable_buffer buffer() { return net::buffer(m_data, max_msg_length); }
+      net::mutable_buffer buffer(size_t size) { return net::buffer(m_data, size); }
+
+      std::string message() const { return std::string(m_data); }
+
+      // This is a CompletionCondition for net::async_read().
+      // Our toy protocol always expects a single \0-terminated string.
+      std::size_t received_zero_byte(const boost::system::error_code& error,
+                                     std::size_t bytes_transferred)
+         {
+         if(error)
+            {
+            return 0;
+            }
+
+         if(bytes_transferred > 0 && m_data[bytes_transferred - 1] == '\0')
+            {
+            return 0;
+            }
+
+         return max_msg_length - bytes_transferred;
+         };
+
+   protected:
+      Botan::AutoSeeded_RNG m_rng;
+      Basic_Credentials_Manager m_credentials_manager;
+      Botan::TLS::Session_Manager_Noop m_session_mgr;
+      Botan::TLS::Policy m_policy;
+      Botan::TLS::Context m_ctx;
+      std::unique_ptr<ssl_stream> m_stream;
+
+      char m_data[max_msg_length];
+   };
+
+class Result_Wrapper
+   {
+   public:
+      Result_Wrapper(net::io_context& ioc, const std::string& name) : m_timer(ioc), m_result(name) {}
+
+      Result& result() { return m_result; }
+
+      void set_timer(const std::string& msg)
+         {
+         m_timer.expires_after(k_timeout);
+         m_timer.async_wait([this, msg](const error_code &ec)
+            {
+            if(ec != net::error::operation_aborted)  // timer cancelled
+               {
+               m_result.test_failure(m_result.who() + ": timeout in " + msg);
+               throw Timeout_Exception(m_result.who());
+               }
+            });
+         }
+
+      void stop_timer()
+         {
+         m_timer.cancel();
+         }
+
+      void expect_success(const std::string& msg, const error_code& ec)
+         {
+         error_code success;
+         expect_ec(msg, success, ec);
+         }
+
+      void expect_ec(const std::string& msg, const error_code& expected, const error_code& ec)
+         {
+         if(ec != expected)
+            { m_result.test_failure(msg, "Unexpected error code: " + ec.message()); }
+         else
+            { m_result.test_success(msg); }
+         }
+
+      void confirm(const std::string& msg, bool condition)
+         {
+         m_result.confirm(msg, condition);
+         }
+
+      void test_failure(const std::string& msg)
+         {
+         m_result.test_failure(msg);
+         }
+
+   private:
+      net::system_timer m_timer;
+      Result m_result;
+   };
+
+class Server : public Side, public std::enable_shared_from_this<Server>
+   {
+   public:
+      Server(net::io_context& ioc)
+         : Side(server_cert(), server_key()),
+           m_acceptor(ioc),
+           m_result(ioc, "Server"),
+           m_short_read_expected(false) {}
+
+      // Control messages
+      // The messages below can be used by the test clients in order to configure the server's behavior during a test
+      // case.
+      //
+      // Tell the server that the next read should result in a StreamTruncated error
+      std::string expect_short_read_message = "SHORT_READ";
+      // Prepare the server for the test case "Shutdown No Response"
+      std::string prepare_shutdown_no_response_message = "SHUTDOWN_NOW";
+
+      void listen()
+         {
+         error_code ec;
+         const auto endpoint = k_endpoints.back();
+
+         m_acceptor.open(endpoint.protocol(), ec);
+         m_acceptor.set_option(net::socket_base::reuse_address(true), ec);
+         m_acceptor.bind(endpoint, ec);
+         m_acceptor.listen(net::socket_base::max_listen_connections, ec);
+
+         m_result.expect_success("listen", ec);
+
+         m_result.set_timer("accept");
+         m_acceptor.async_accept(std::bind(&Server::start_session, shared_from_this(), _1, _2));
+         }
+
+      void expect_short_read()
+         {
+         m_short_read_expected = true;
+         }
+
+      Result result() { return m_result.result(); }
+
+   private:
+      void start_session(const error_code& ec, tcp::socket socket)
+         {
+         // Note: If this fails with 'Operation canceled', it likely means the timer expired and the port is taken.
+         m_result.expect_success("accept", ec);
+
+         // Note: If this was a real server, we should create a new session (with its own stream) for each accepted
+         // connection. In this test we only have one connection.
+         m_stream = std::unique_ptr<ssl_stream>(new ssl_stream(std::move(socket), m_ctx));
+
+         m_result.set_timer("handshake");
+         m_stream->async_handshake(Botan::TLS::Connection_Side::SERVER,
+                                   std::bind(&Server::handle_handshake, shared_from_this(), _1));
+         }
+
+      void shutdown()
+         {
+         error_code shutdown_ec;
+         m_stream->shutdown(shutdown_ec);
+         m_result.expect_success("shutdown", shutdown_ec);
+         handle_write(error_code{});
+         }
+
+
+      void handle_handshake(const error_code& ec)
+         {
+         m_result.expect_success("handshake", ec);
+         handle_write(error_code{});
+         }
+
+      void handle_write(const error_code& ec)
+         {
+         m_result.expect_success("send_response", ec);
+         m_result.set_timer("read_message");
+         net::async_read(*m_stream, buffer(),
+                         std::bind(&Server::received_zero_byte, shared_from_this(), _1, _2),
+                         std::bind(&Server::handle_read, shared_from_this(), _1, _2));
+         }
+
+      void handle_read(const error_code& ec, size_t bytes_transferred=0)
+         {
+         if(m_short_read_expected)
+            {
+            m_result.expect_ec("received stream truncated error", Botan::TLS::StreamTruncated, ec);
+            quit();
+            return;
+            }
+
+         if(ec)
+            {
+            if(m_stream->shutdown_received())
+               {
+               m_result.expect_ec("received EOF after close_notify", net::error::eof, ec);
+               m_result.set_timer("shutdown");
+               m_stream->async_shutdown(std::bind(&Server::handle_shutdown, shared_from_this(), _1));
+               }
+            else
+               {
+               m_result.test_failure("Unexpected error code: " + ec.message());
+               quit();
+               }
+            return;
+            }
+
+         m_result.expect_success("read_message", ec);
+
+         if(message() == prepare_shutdown_no_response_message)
+            {
+            m_short_read_expected = true;
+            shutdown();
+            return;
+            }
+
+         if(message() == expect_short_read_message)
+            {
+            m_short_read_expected = true;
+            }
+
+         m_result.set_timer("send_response");
+         net::async_write(*m_stream, buffer(bytes_transferred),
+                          std::bind(&Server::handle_write, shared_from_this(), _1));
+         }
+
+      void handle_shutdown(const error_code& ec)
+         {
+         m_result.expect_success("shutdown", ec);
+         quit();
+         }
+
+      void quit()
+         {
+         m_result.stop_timer();
+         }
+
+   private:
+      tcp::acceptor m_acceptor;
+      Result_Wrapper m_result;
+      bool m_short_read_expected;
+   };
+
+class Client : public Side
+   {
+      static void accept_all(
+         const std::vector<Botan::X509_Certificate>&,
+         const std::vector<std::shared_ptr<const Botan::OCSP::Response>>&,
+         const std::vector<Botan::Certificate_Store*>&, Botan::Usage_Type,
+         const std::string&, const Botan::TLS::Policy&) {}
+
+   public:
+      Client(net::io_context& ioc)
+         : Side()
+         {
+         m_ctx.set_verify_callback(accept_all);
+         m_stream = std::unique_ptr<ssl_stream>(new ssl_stream(ioc, m_ctx));
+         }
+
+      ssl_stream& stream() {return *m_stream; }
+
+      void close_socket()
+         {
+         // Shutdown on TCP level before closing the socket for portable behavior. Otherwise the peer will see a
+         // connection_reset error rather than EOF on Windows.
+         // See the remark in
+         // https://www.boost.org/doc/libs/1_68_0/doc/html/boost_asio/reference/basic_stream_socket/close/overload1.html
+         m_stream->lowest_layer().shutdown(tcp::socket::shutdown_both);
+         m_stream->lowest_layer().close();
+         }
+
+   };
+
+#include <boost/asio/yield.hpp>
+
+class TestBase
+   {
+   public:
+      TestBase(net::io_context& ioc, std::shared_ptr<Server> server, const std::string& name)
+         : m_client(ioc),
+           m_server(server),
+           m_result(ioc, name) {}
+
+      virtual void finishAsynchronousWork() {}
+
+      Result result() { return m_result.result(); }
+
+   protected:
+      Client m_client;
+      std::shared_ptr<Server> m_server;
+      Result_Wrapper m_result;
+   };
+
+class Synchronous_Test : public TestBase
+   {
+   public:
+      using TestBase::TestBase;
+
+      void finishAsynchronousWork() override
+         {
+         m_client_thread.join();
+         }
+
+      void run(const error_code&)
+         {
+         m_client_thread = std::thread(std::bind(&Synchronous_Test::run_synchronous_client, this));
+         }
+
+      virtual void run_synchronous_client() = 0;
+
+   private:
+      std::thread m_client_thread;
+   };
+
+/* In this test case both parties perform the handshake, exchange a message, and do a full shutdown.
+ *
+ * The client expects the server to echo the same message it sent. The client then initiates the shutdown. The server is
+ * expected to receive a close_notify and complete its shutdown with an error_code Success, the client is expected to
+ * receive a close_notify and complete its shutdown with an error_code EOF.
+ */
+class Test_Conversation : public TestBase, public net::coroutine, public std::enable_shared_from_this<Test_Conversation>
+   {
+   public:
+      Test_Conversation(net::io_context& ioc, std::shared_ptr<Server> server)
+         : TestBase(ioc, server, "Test Conversation") {}
+
+      void run(const error_code& ec)
+         {
+         static auto test_case = &Test_Conversation::run;
+         const std::string message("Time is an illusion. Lunchtime doubly so.");
+
+         reenter(*this)
+            {
+            m_result.set_timer("connect");
+            yield net::async_connect(m_client.stream().lowest_layer(), k_endpoints,
+                                     std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("connect", ec);
+
+            m_result.set_timer("handshake");
+            yield m_client.stream().async_handshake(Botan::TLS::Connection_Side::CLIENT,
+                                                    std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("handshake", ec);
+
+            m_result.set_timer("send_message");
+            yield net::async_write(m_client.stream(),
+                                   net::buffer(message.c_str(), message.size() + 1), // including \0 termination
+                                   std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("send_message", ec);
+
+            m_result.set_timer("receive_response");
+            yield net::async_read(m_client.stream(),
+                                  m_client.buffer(),
+                                  std::bind(&Client::received_zero_byte, &m_client, _1, _2),
+                                  std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("receive_response", ec);
+            m_result.confirm("correct message", m_client.message() == message);
+
+            m_result.set_timer("shutdown");
+            yield m_client.stream().async_shutdown(std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("shutdown", ec);
+
+            m_result.set_timer("await close_notify");
+            yield net::async_read(m_client.stream(), m_client.buffer(),
+                                  std::bind(test_case, shared_from_this(), _1));
+            m_result.confirm("received close_notify", m_client.stream().shutdown_received());
+            m_result.expect_ec("closed with EOF", net::error::eof, ec);
+
+            m_result.stop_timer();
+            }
+         }
+   };
+
+class Test_Conversation_Sync : public Synchronous_Test
+   {
+   public:
+      Test_Conversation_Sync(net::io_context& ioc, std::shared_ptr<Server> server)
+         : Synchronous_Test(ioc, server, "Test Conversation Sync") {}
+
+      void run_synchronous_client() override
+         {
+         const std::string message("Time is an illusion. Lunchtime doubly so.");
+         error_code ec;
+
+         net::connect(m_client.stream().lowest_layer(), k_endpoints, ec);
+         m_result.expect_success("connect", ec);
+
+         m_client.stream().handshake(Botan::TLS::Connection_Side::CLIENT, ec);
+         m_result.expect_success("handshake", ec);
+
+         net::write(m_client.stream(),
+                    net::buffer(message.c_str(), message.size() + 1), // including \0 termination
+                    ec);
+         m_result.expect_success("send_message", ec);
+
+         net::read(m_client.stream(),
+                   m_client.buffer(),
+                   std::bind(&Client::received_zero_byte, &m_client, _1, _2),
+                   ec);
+         m_result.expect_success("receive_response", ec);
+         m_result.confirm("correct message", m_client.message() == message);
+
+         m_client.stream().shutdown(ec);
+         m_result.expect_success("shutdown", ec);
+
+         net::read(m_client.stream(), m_client.buffer(), ec);
+         m_result.confirm("received close_notify", m_client.stream().shutdown_received());
+         m_result.expect_ec("closed with EOF", net::error::eof, ec);
+         }
+   };
+
+/* In this test case the client shuts down the SSL connection, but does not wait for the server's response before
+ * closing the socket. Accordingly, it will not receive the server's close_notify alert. Instead, the async_read
+ * operation will be aborted. The server should be able to successfully shutdown nonetheless.
+ */
+class Test_Eager_Close : public TestBase, public net::coroutine, public std::enable_shared_from_this<Test_Eager_Close>
+   {
+   public:
+      Test_Eager_Close(net::io_context& ioc, std::shared_ptr<Server> server)
+         : TestBase(ioc, server, "Test Eager Close") {}
+
+      void run(const error_code& ec)
+         {
+         static auto test_case = &Test_Eager_Close::run;
+         reenter(*this)
+            {
+            m_result.set_timer("connect");
+            yield net::async_connect(m_client.stream().lowest_layer(), k_endpoints,
+                                     std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("connect", ec);
+
+            m_result.set_timer("handshake");
+            yield m_client.stream().async_handshake(Botan::TLS::Connection_Side::CLIENT,
+                                                    std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("handshake", ec);
+
+            m_result.set_timer("shutdown");
+            yield m_client.stream().async_shutdown(std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("shutdown", ec);
+            m_result.stop_timer();
+
+            m_client.close_socket();
+            m_result.confirm("did not receive close_notify", !m_client.stream().shutdown_received());
+            }
+         }
+   };
+
+class Test_Eager_Close_Sync : public Synchronous_Test
+   {
+   public:
+      Test_Eager_Close_Sync(net::io_context& ioc, std::shared_ptr<Server> server)
+         : Synchronous_Test(ioc, server, "Test Eager Close Sync") {}
+
+      void run_synchronous_client()
+         {
+         error_code ec;
+
+         net::connect(m_client.stream().lowest_layer(), k_endpoints, ec);
+         m_result.expect_success("connect", ec);
+
+         m_client.stream().handshake(Botan::TLS::Connection_Side::CLIENT, ec);
+         m_result.expect_success("handshake", ec);
+
+         m_client.stream().shutdown(ec);
+         m_result.expect_success("shutdown", ec);
+
+         m_client.close_socket();
+         m_result.confirm("did not receive close_notify", !m_client.stream().shutdown_received());
+         }
+   };
+
+/* In this test case the client closes the socket without properly shutting down the connection.
+ * The server should see a StreamTruncated error.
+ */
+class Test_Close_Without_Shutdown
+   : public TestBase,
+     public net::coroutine,
+     public std::enable_shared_from_this<Test_Close_Without_Shutdown>
+   {
+   public:
+      Test_Close_Without_Shutdown(net::io_context& ioc, std::shared_ptr<Server> server)
+         : TestBase(ioc, server, "Test Close Without Shutdown") {}
+
+      void run(const error_code& ec)
+         {
+         static auto test_case = &Test_Close_Without_Shutdown::run;
+         reenter(*this)
+            {
+            m_result.set_timer("connect");
+            yield net::async_connect(m_client.stream().lowest_layer(), k_endpoints,
+                                     std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("connect", ec);
+
+            m_result.set_timer("handshake");
+            yield m_client.stream().async_handshake(Botan::TLS::Connection_Side::CLIENT,
+                                                    std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("handshake", ec);
+
+            // send the control message to configure the server to expect a short-read
+            m_result.set_timer("send expect_short_read message");
+            yield net::async_write(m_client.stream(),
+                                   net::buffer(m_server->expect_short_read_message.c_str(),
+                                               m_server->expect_short_read_message.size() + 1), // including \0 termination
+                                   std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("send expect_short_read message", ec);
+
+            // read the confirmation of the control message above
+            m_result.set_timer("receive_response");
+            yield net::async_read(m_client.stream(),
+                                  m_client.buffer(),
+                                  std::bind(&Client::received_zero_byte, &m_client, _1, _2),
+                                  std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("receive_response", ec);
+
+            m_result.stop_timer();
+
+            m_client.close_socket();
+            m_result.confirm("did not receive close_notify", !m_client.stream().shutdown_received());
+            }
+         }
+   };
+
+class Test_Close_Without_Shutdown_Sync : public Synchronous_Test
+   {
+   public:
+      Test_Close_Without_Shutdown_Sync(net::io_context& ioc, std::shared_ptr<Server> server)
+         : Synchronous_Test(ioc, server, "Test Close Without Shutdown Sync") {}
+
+      void run_synchronous_client()
+         {
+         error_code ec;
+         net::connect(m_client.stream().lowest_layer(), k_endpoints, ec);
+         m_result.expect_success("connect", ec);
+
+         m_client.stream().handshake(Botan::TLS::Connection_Side::CLIENT, ec);
+         m_result.expect_success("handshake", ec);
+
+         m_server->expect_short_read();
+
+         m_client.close_socket();
+         m_result.confirm("did not receive close_notify", !m_client.stream().shutdown_received());
+         }
+   };
+
+/* In this test case the server shuts down the connection but the client doesn't send the corresponding close_notify
+ * response. Instead, it closes the socket immediately.
+ * The server should see a short-read error.
+ */
+class Test_No_Shutdown_Response : public TestBase, public net::coroutine,
+   public std::enable_shared_from_this<Test_No_Shutdown_Response>
+   {
+   public:
+      Test_No_Shutdown_Response(net::io_context& ioc, std::shared_ptr<Server> server)
+         : TestBase(ioc, server, "Test No Shutdown Response") {}
+
+      void run(const error_code& ec)
+         {
+         static auto test_case = &Test_No_Shutdown_Response::run;
+         reenter(*this)
+            {
+            m_result.set_timer("connect");
+            yield net::async_connect(m_client.stream().lowest_layer(), k_endpoints,
+                                     std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("connect", ec);
+
+            m_result.set_timer("handshake");
+            yield m_client.stream().async_handshake(Botan::TLS::Connection_Side::CLIENT,
+                                                    std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("handshake", ec);
+
+            // send a control message to make the server shut down
+            m_result.set_timer("send shutdown message");
+            yield net::async_write(m_client.stream(),
+                                   net::buffer(m_server->prepare_shutdown_no_response_message.c_str(),
+                                               m_server->prepare_shutdown_no_response_message.size() + 1), // including \0 termination
+                                   std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_success("send shutdown message", ec);
+
+            // read the server's close-notify message
+            m_result.set_timer("read close_notify");
+            yield net::async_read(m_client.stream(), m_client.buffer(),
+                                  std::bind(test_case, shared_from_this(), _1));
+            m_result.expect_ec("read gives EOF", net::error::eof, ec);
+            m_result.confirm("received close_notify", m_client.stream().shutdown_received());
+
+            m_result.stop_timer();
+
+            // close the socket rather than shutting down
+            m_client.close_socket();
+            }
+         }
+   };
+
+class Test_No_Shutdown_Response_Sync : public Synchronous_Test
+   {
+   public:
+      Test_No_Shutdown_Response_Sync(net::io_context& ioc, std::shared_ptr<Server> server)
+         : Synchronous_Test(ioc, server, "Test No Shutdown Response Sync") {}
+
+      void run_synchronous_client()
+         {
+         error_code ec;
+         net::connect(m_client.stream().lowest_layer(), k_endpoints, ec);
+         m_result.expect_success("connect", ec);
+
+         m_client.stream().handshake(Botan::TLS::Connection_Side::CLIENT, ec);
+         m_result.expect_success("handshake", ec);
+
+         net::write(m_client.stream(),
+                    net::buffer(m_server->prepare_shutdown_no_response_message.c_str(),
+                                m_server->prepare_shutdown_no_response_message.size() + 1), // including \0 termination
+                    ec);
+         m_result.expect_success("send expect_short_read message", ec);
+
+         net::read(m_client.stream(), m_client.buffer(), ec);
+         m_result.expect_ec("read gives EOF", net::error::eof, ec);
+         m_result.confirm("received close_notify", m_client.stream().shutdown_received());
+
+         // close the socket rather than shutting down
+         m_client.close_socket();
+         }
+   };
+
+#include <boost/asio/unyield.hpp>
+
+template<typename TestT>
+void run_test_case(std::vector<Result>& results)
+   {
+   net::io_context ioc;
+
+   auto s = std::make_shared<Server>(ioc);
+   s->listen();
+
+   auto t = std::make_shared<TestT>(ioc, s);
+   t->run(error_code{});
+
+   try
+      {
+      ioc.run();
+      }
+   catch(Timeout_Exception&) { /* the test result will already contain a failure */ }
+
+   t->finishAsynchronousWork();
+
+   results.push_back(s->result());
+   results.push_back(t->result());
+   }
+
+}  // namespace
+
+namespace Botan_Tests {
+
+class Tls_Stream_Integration_Tests final : public Test
+   {
+   public:
+      std::vector<Test::Result> run() override
+         {
+         std::vector<Test::Result> results;
+
+         run_test_case<Test_Conversation>(results);
+         run_test_case<Test_Eager_Close>(results);
+         run_test_case<Test_Close_Without_Shutdown>(results);
+         run_test_case<Test_No_Shutdown_Response>(results);
+         run_test_case<Test_Conversation_Sync>(results);
+         run_test_case<Test_Eager_Close_Sync>(results);
+         run_test_case<Test_Close_Without_Shutdown_Sync>(results);
+         run_test_case<Test_No_Shutdown_Response_Sync>(results);
+
+         return results;
+         }
+   };
+
+BOTAN_REGISTER_TEST("tls_stream_integration", Tls_Stream_Integration_Tests);
+
+}  // namespace Botan_Tests
+
+#endif // BOOST_VERSION
+#endif // BOTAN_HAS_TLS && BOTAN_HAS_BOOST_ASIO
diff --git a/src/tests/unit_asio_stream.cpp b/src/tests/unit_asio_stream.cpp
index c32cfb9d8..8691cb211 100644
--- a/src/tests/unit_asio_stream.cpp
+++ b/src/tests/unit_asio_stream.cpp
@@ -1,14 +1,14 @@
 /*
 * TLS ASIO Stream Unit Tests
-* (C) 2018-2019 Jack Lloyd
-*     2018-2019 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
+* (C) 2018-2020 Jack Lloyd
+*     2018-2020 Hannes Rantzsch, Tim Oesterreich, Rene Meusel
 *
 * Botan is released under the Simplified BSD License (see license.txt)
 */
 
 #include "tests.h"
 
-#if defined(BOTAN_HAS_TLS) && defined(BOTAN_HAS_BOOST_ASIO)
+#if defined(BOTAN_HAS_TLS) && defined(BOTAN_HAS_TLS_ASIO_STREAM)
 
 #include <botan/asio_stream.h>
 #include <botan/tls_callbacks.h>
@@ -177,7 +177,7 @@ class Asio_Stream_Tests final : public Test
          {
          net::io_context ioc;
          // fail right away
-         FailCount  fc{0, net::error::eof};
+         FailCount  fc{0, net::error::no_recovery};
          TestStream remote{ioc};
 
          auto ctx = get_context();
@@ -192,7 +192,7 @@ class Asio_Stream_Tests final : public Test
 
          Test::Result result("sync TLS handshake error");
          result.test_eq("does not activate channel", ssl.native_handle()->is_active(), false);
-         result.confirm("propagates error code", ec == net::error::eof);
+         result.confirm("propagates error code", ec == net::error::no_recovery);
          results.push_back(result);
          }
 
@@ -246,7 +246,7 @@ class Asio_Stream_Tests final : public Test
          {
          net::io_context ioc;
          // fail right away
-         FailCount  fc{0, net::error::eof};
+         FailCount  fc{0, net::error::no_recovery};
          TestStream remote{ioc};
 
          auto ctx = get_context();
@@ -261,7 +261,7 @@ class Asio_Stream_Tests final : public Test
          auto handler = [&](const error_code &ec)
             {
             result.test_eq("does not activate channel", ssl.native_handle()->is_active(), false);
-            result.confirm("propagates error code", ec == net::error::eof);
+            result.confirm("propagates error code", ec == net::error::no_recovery);
             };
 
          ssl.async_handshake(Botan::TLS::CLIENT, handler);
@@ -345,7 +345,7 @@ class Asio_Stream_Tests final : public Test
          {
          net::io_context ioc;
          // fail right away
-         FailCount  fc{0, net::error::eof};
+         FailCount  fc{0, net::error::no_recovery};
          TestStream remote{ioc};
 
          auto ctx = get_context();
@@ -359,7 +359,7 @@ class Asio_Stream_Tests final : public Test
 
          Test::Result result("sync read_some error");
          result.test_eq("didn't transfer anything", bytes_transferred, 0);
-         result.confirm("propagates error code", ec == net::error::eof);
+         result.confirm("propagates error code", ec == net::error::no_recovery);
 
          results.push_back(result);
          }
@@ -467,7 +467,7 @@ class Asio_Stream_Tests final : public Test
          {
          net::io_context ioc;
          // fail right away
-         FailCount  fc{0, net::error::eof};
+         FailCount  fc{0, net::error::no_recovery};
          auto ctx = get_context();
          AsioStream ssl(ctx, ioc, fc);
          uint8_t    data[TEST_DATA_SIZE];
@@ -477,7 +477,7 @@ class Asio_Stream_Tests final : public Test
          auto read_handler = [&](const error_code &ec, std::size_t bytes_transferred)
             {
             result.test_eq("didn't transfer anything", bytes_transferred, 0);
-            result.confirm("propagates error code", ec == net::error::eof);
+            result.confirm("propagates error code", ec == net::error::no_recovery);
             };
 
          net::mutable_buffer buf {data, TEST_DATA_SIZE};
@@ -558,6 +558,24 @@ class Asio_Stream_Tests final : public Test
          results.push_back(result);
          }
 
+      void test_sync_no_handshake(std::vector<Test::Result>& results)
+         {
+         net::io_context ioc;
+         TestStream      remote{ioc};
+
+         auto ctx = get_context();
+         Botan::TLS::Stream<TestStream> ssl(ctx, ioc);  // Note that we're not using MockChannel here
+         ssl.next_layer().connect(remote);
+         error_code ec;
+
+         net::write(ssl, net::const_buffer(TEST_DATA, TEST_DATA_SIZE), ec);
+
+         Test::Result result("sync write_some without handshake fails gracefully");
+         result.confirm("reports an error", ec.failed());
+
+         results.push_back(result);
+         }
+
       void test_sync_write_some_buffer_sequence(std::vector<Test::Result>& results)
          {
          net::io_context ioc;
@@ -600,7 +618,7 @@ class Asio_Stream_Tests final : public Test
          {
          net::io_context ioc;
          // fail right away
-         FailCount  fc{0, net::error::eof};
+         FailCount  fc{0, net::error::no_recovery};
          TestStream remote{ioc};
 
          auto ctx = get_context();
@@ -613,7 +631,7 @@ class Asio_Stream_Tests final : public Test
 
          Test::Result result("sync write_some error");
          result.test_eq("didn't transfer anything", bytes_transferred, 0);
-         result.confirm("propagates error code", ec == net::error::eof);
+         result.confirm("propagates error code", ec == net::error::no_recovery);
 
          results.push_back(result);
          }
@@ -706,7 +724,7 @@ class Asio_Stream_Tests final : public Test
          {
          net::io_context ioc;
          // fail right away
-         FailCount  fc{0, net::error::eof};
+         FailCount  fc{0, net::error::no_recovery};
          TestStream remote{ioc};
 
          auto ctx = get_context();
@@ -718,7 +736,7 @@ class Asio_Stream_Tests final : public Test
          auto write_handler = [&](const error_code &ec, std::size_t bytes_transferred)
             {
             result.test_eq("committed some bytes to the core", bytes_transferred, TEST_DATA_SIZE);
-            result.confirm("propagates error code", ec == net::error::eof);
+            result.confirm("propagates error code", ec == net::error::no_recovery);
             };
 
          net::async_write(ssl, net::const_buffer(TEST_DATA, TEST_DATA_SIZE), write_handler);
@@ -755,6 +773,8 @@ class Asio_Stream_Tests final : public Test
          {
          std::vector<Test::Result> results;
 
+         test_sync_no_handshake(results);
+
          test_sync_handshake(results);
          test_sync_handshake_error(results);
          test_sync_handshake_throw(results);