Repository: randombit/botan
Pull request #2974 (2022-05-05 14:38:02)

[TLS 1.3] Session Resumption
This adds session resumption to [the TLS 1.3 implementation](https://github.com/randombit/botan/pull/2922).

Central changes in this pull request:

* Extend the `TLS::Session` class to allow storing TLS 1.3 sessions
* Provide a new entry point into the TLS 1.3 key schedule: `init_with_psk()`
* Allow premature serialization of `Client_Hello_13` to calculate PSK binders
  * Implement a `Truncate(Client_Hello)` function as outlined in [RFC 8446 4.2.11.2](https://www.rfc-editor.org/rfc/rfc8446.html#section-4.2.11.2)
  * Extend the TLS client state machine to accomodate resumption
* ~~Provide `TLS::Callbacks::tls_current_timestamp()` for deterministic timestamps for testing~~ (now extracted [here](https://github.com/randombit/botan/pull/2975))

Currently, the TLS 1.3 PSK mechanism is exclusively used for session resumption. It should be fairly straight forward to extend it for out-of-band PSKs. Furthermore, this pull request leaves support for early data and early key export to future work.

Caveat: [RFC 8446 suggests to not reuse session tickets](https://www.rfc-editor.org/rfc/rfc8446.html#appendix-C.4) to prevent passive client tracking. This is not implemented by the client implementation (neither in TLS 1.2). I suppose this is meant to be implemented by a custom `Session_Manager` if needed?!

codecov-commenter: # [Codecov](https://codecov.io/gh/randombit/botan/pull/2974?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd) Report
> Merging [#2974](https://codecov.io/gh/randombit/botan/pull/2974?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd) (0802b9d) into [master](https://codecov.io/gh/randombit/botan/commit/2fb7ba81b0304f0864a557e3f3a010f4c2ce08d3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd) (2fb7ba8) will **decrease** coverage by `0.01%`.
> The diff coverage is `91.91%`.

```diff
@@            Coverage Diff             @@
##           master    #2974      +/-   ##
==========================================
- Coverage   92.56%   92.55%   -0.02%     
==========================================
  Files         577      594      +17     
  Lines       66736    69789    +3053     
  Branches     6384     6610     +226     
==========================================
+ Hits        61777    64594    +2817     
- Misses       4926     5162     +236     
  Partials       33       33              
```


| [Impacted Files](https://codecov.io/gh/randombit/botan/pull/2974?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd) | Coverage Δ | |
|---|---|---|
| [src/cli/tls\_utils.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2NsaS90bHNfdXRpbHMuY3Bw) | `77.64% <ø> (ø)` | |
| [src/lib/tls/msg\_cert\_req.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvbXNnX2NlcnRfcmVxLmNwcA==) | `89.85% <ø> (ø)` | |
| [src/lib/tls/tls12/tls\_channel\_impl\_12.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzMTIvdGxzX2NoYW5uZWxfaW1wbF8xMi5jcHA=) | `89.04% <0.00%> (-0.81%)` | :arrow_down: |
| [src/lib/tls/tls12/tls\_server\_impl\_12.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzMTIvdGxzX3NlcnZlcl9pbXBsXzEyLmNwcA==) | `88.38% <ø> (ø)` | |
| [src/lib/tls/tls\_callbacks.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzX2NhbGxiYWNrcy5jcHA=) | `76.62% <0.00%> (-9.87%)` | :arrow_down: |
| [src/lib/tls/tls\_server.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzX3NlcnZlci5jcHA=) | `65.90% <0.00%> (-4.83%)` | :arrow_down: |
| [src/lib/tls/tls\_suite\_info.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzX3N1aXRlX2luZm8uY3Bw) | `100.00% <ø> (ø)` | |
| [src/lib/tls/tls\_algos.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzX2FsZ29zLmNwcA==) | `69.61% <38.88%> (-14.33%)` | :arrow_down: |
| [src/lib/tls/tls12/tls\_handshake\_state.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2xpYi90bHMvdGxzMTIvdGxzX2hhbmRzaGFrZV9zdGF0ZS5jcHA=) | `84.03% <47.05%> (-4.80%)` | :arrow_down: |
| [src/cli/tls\_client.cpp](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd#diff-c3JjL2NsaS90bHNfY2xpZW50LmNwcA==) | `74.41% <66.66%> (-0.59%)` | :arrow_down: |
| ... and [61 more](https://codecov.io/gh/randombit/botan/pull/2974/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/randombit/botan/pull/2974?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/randombit/botan/pull/2974?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd). Last update [2fb7ba8...0802b9d](https://codecov.io/gh/randombit/botan/pull/2974?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jack+Lloyd).

reneme: Rebased and retargeted to master.

Patch:
diff --git a/configure.py b/configure.py
index b45f5087b..2f3743405 100755
--- a/configure.py
+++ b/configure.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 # pylint: disable=too-many-lines
 
@@ -7,12 +7,16 @@ Configuration program for botan
 
 (C) 2009-2020 Jack Lloyd
 (C) 2015,2016,2017 Simon Warta (Kullo GmbH)
-(C) 2019-2022 René Meusel (neXenio GmbH, Rohde & Schwarz Cybersecurity GmbH)
 
 Botan is released under the Simplified BSD License (see license.txt)
 
-This script is regularly tested with CPython 3.x, and
-occasionally tested PyPy 4.
+This script is regularly tested with CPython 2.7 and 3.5, and
+occasionally tested with CPython 2.6 and PyPy 4.
+
+Support for CPython 2.6 will be dropped eventually, but is kept up for as
+long as reasonably convenient.
+
+CPython 2.5 and earlier are not supported.
 
 On Jython target detection does not work (use --os and --cpu).
 """
@@ -77,7 +81,7 @@ def parse_version_file(version_path):
             results[key] = val
     return results
 
-class Version:
+class Version(object):
     """
     Version information are all static members
     """
@@ -169,7 +173,7 @@ class Version:
 
 
 
-class SourcePaths:
+class SourcePaths(object):
     """
     A collection of paths defined by the project structure and
     independent of user configurations.
@@ -193,7 +197,7 @@ class SourcePaths:
         self.sphinx_config_dir = os.path.join(self.configs_dir, 'sphinx')
 
 
-class BuildPaths: # pylint: disable=too-many-instance-attributes
+class BuildPaths(object): # pylint: disable=too-many-instance-attributes
     """
     Constructor
     """
@@ -699,7 +703,7 @@ def take_options_from_env(options):
     options.cxxflags = update_from_env(options.cxxflags, 'CXXFLAGS', 'cxxflags')
     options.ldflags = update_from_env(options.ldflags, 'LDFLAGS', 'ldflags')
 
-class LexResult:
+class LexResult(object):
     pass
 
 
@@ -788,7 +792,7 @@ def lex_me_harder(infofile, allowed_groups, allowed_maps, name_val_pairs):
 
     return out
 
-class InfoObject:
+class InfoObject(object):
     def __init__(self, infofile):
         """
         Constructor sets members `infofile`, `lives_in`, `parent_module` and `basename`
@@ -1705,7 +1709,7 @@ def process_template_string(template_text, variables, template_source):
 
     The template language supports (un-nested) conditionals.
     """
-    class SimpleTemplate:
+    class SimpleTemplate(object):
 
         def __init__(self, vals):
             self.vals = vals
@@ -2300,7 +2304,7 @@ def create_template_vars(source_paths, build_paths, options, modules, cc, arch,
 
     return variables
 
-class ModulesChooser:
+class ModulesChooser(object):
     """
     Determine which modules to load based on options, target, etc
     """
@@ -2609,7 +2613,7 @@ def portable_symlink(file_path, target_dir, method):
         raise UserError('Unknown link method %s' % (method))
 
 
-class AmalgamationHelper:
+class AmalgamationHelper(object):
     # All include types may have trailing comment like e.g. '#include <vector> // IWYU pragma: export'
     _any_include = re.compile(r'#include <(.*)>')
     _botan_include = re.compile(r'#include <botan/(.*)>')
@@ -2663,7 +2667,7 @@ class AmalgamationHelper:
 """ % (Version.as_string()))
 
 
-class AmalgamationHeader:
+class AmalgamationHeader(object):
     def __init__(self, input_filepaths):
 
         self.included_already = set()
@@ -2724,13 +2728,16 @@ class AmalgamationHeader:
             f.write("\n#endif // %s\n" % (include_guard))
 
 
-class AmalgamationGenerator:
+class AmalgamationGenerator(object):
     _header_guard_pattern = re.compile(r'^#define BOTAN_.*_H_\s*$')
     _header_endif_pattern = re.compile(r'^#endif.*$')
 
     @staticmethod
     def read_header(filepath):
-        with open(filepath, encoding='utf8') as f:
+        encoding_kwords = {}
+        if sys.version_info[0] == 3:
+            encoding_kwords['encoding'] = 'utf8'
+        with open(filepath, **encoding_kwords) as f:
             raw_content = f.readlines()
             return AmalgamationGenerator.strip_header_goop(filepath, raw_content)
 
@@ -2770,6 +2777,10 @@ class AmalgamationGenerator:
         self._options = options
 
     def generate(self):
+        encoding_kwords = {}
+        if sys.version_info[0] == 3:
+            encoding_kwords['encoding'] = 'utf8'
+
         pub_header_amalag = AmalgamationHeader(self._build_paths.public_headers)
         amalgamation_header_fsname = '%s.h' % (self._filename_prefix)
         logging.info('Writing amalgamation header to %s', amalgamation_header_fsname)
@@ -2784,7 +2795,7 @@ class AmalgamationGenerator:
         amalgamation_fsname = '%s.cpp' % (self._filename_prefix)
         logging.info('Writing amalgamation source to %s', amalgamation_fsname)
 
-        amalgamation_file = open(amalgamation_fsname, 'w', encoding='utf8')
+        amalgamation_file = open(amalgamation_fsname, 'w', **encoding_kwords)
 
         AmalgamationHelper.write_banner(amalgamation_file)
         amalgamation_file.write('\n#include "%s"\n\n' % (amalgamation_header_fsname))
@@ -2797,7 +2808,7 @@ class AmalgamationGenerator:
 
         for mod in sorted(self._modules, key=lambda module: module.basename):
             for src in sorted(mod.source):
-                with open(src, 'r', encoding='utf8') as f:
+                with open(src, 'r', **encoding_kwords) as f:
                     for line in f:
                         if AmalgamationHelper.is_botan_include(line):
                             # Botan headers are inlined in amalgamation headers
@@ -2842,7 +2853,7 @@ def have_program(program):
     return False
 
 
-class BotanConfigureLogHandler(logging.StreamHandler):
+class BotanConfigureLogHandler(logging.StreamHandler, object):
     def emit(self, record):
         # Do the default stuff first
         super(BotanConfigureLogHandler, self).emit(record)
diff --git a/doc/building.rst b/doc/building.rst
index f963cc4a8..6240d449c 100644
--- a/doc/building.rst
+++ b/doc/building.rst
@@ -18,7 +18,7 @@ system, primarily due to lack of access and interest.  Please contact the
 maintainer if you would like to build Botan on such a system.
 
 Botan's build is controlled by configure.py, which is a `Python
-<https://www.python.org>`_ script. Python 3.x or later is required.
+<https://www.python.org>`_ script. Python 2.7 or later is required.
 
 .. highlight:: none
 
@@ -31,10 +31,10 @@ For the impatient, this works for most systems::
 Or using ``nmake``, if you're compiling on Windows with Visual C++. On
 platforms that do not understand the '#!' convention for beginning
 script files, or that have Python installed in an unusual spot, you
-might need to prefix the ``configure.py`` command with ``python3`` or
-``/path/to/python3``::
+might need to prefix the ``configure.py`` command with ``python`` or
+``/path/to/python``::
 
-  $ python3 ./configure.py [arguments]
+  $ python ./configure.py [arguments]
 
 Configuring the Build
 ---------------------------------
@@ -42,7 +42,7 @@ Configuring the Build
 The first step is to run ``configure.py``, which is a Python script
 that creates various directories, config files, and a Makefile for
 building everything. This script should run under a vanilla install of
-Python 3.x.
+Python 2.7 or Python 3.x.
 
 The script will attempt to guess what kind of system you are trying to
 compile for (and will print messages telling you what it guessed).
@@ -204,7 +204,7 @@ You need to have a copy of Python installed, and have both Python and
 your chosen compiler in your path. Open a command shell (or the SDK
 shell), and run::
 
-   $ python3 configure.py --cc=msvc --os=windows
+   $ python configure.py --cc=msvc --os=windows
    $ nmake
    $ nmake check
    $ nmake install
@@ -214,7 +214,7 @@ which enables you to run multiple build jobs in parallel.
 
 For MinGW, use::
 
-   $ python3 configure.py --cc=gcc --os=mingw
+   $ python configure.py --cc=gcc --os=mingw
    $ make
 
 By default the install target will be ``C:\botan``; you can modify
diff --git a/doc/deprecated.rst b/doc/deprecated.rst
index d1a807cdd..bfe89a539 100644
--- a/doc/deprecated.rst
+++ b/doc/deprecated.rst
@@ -108,3 +108,9 @@ Other API deprecations
   directly on ``MessageAuthenticationCode`` and ``HashFunction``. So
   this only affects you if you are directly referencing
   ``Botan::Buffered_Computation`` in some way.
+
+Deprecated Build Targets
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+- Configuring a build (with ``configure.py``) using Python2. In a future
+  major release, Python3 will be required.
diff --git a/doc/dev_ref/configure.rst b/doc/dev_ref/configure.rst
index 3e3d669d6..50a639772 100644
--- a/doc/dev_ref/configure.rst
+++ b/doc/dev_ref/configure.rst
@@ -60,6 +60,11 @@ Makefile, ``build.h`` and other artifacts.
 When Modifying ``configure.py``
 --------------------------------
 
+For now, any changes to ``configure.py`` must work under both CPython 2.7 and
+CPython 3.x. In a future major release, support for CPython2 will be dropped,
+but until then if making modifications verify the code works as expected on
+both versions.
+
 Run ``./src/scripts/ci_build.py lint`` to run Pylint checks after any change.
 
 Template Language
diff --git a/doc/dev_ref/contributing.rst b/doc/dev_ref/contributing.rst
index 472d375f8..985e0479d 100644
--- a/doc/dev_ref/contributing.rst
+++ b/doc/dev_ref/contributing.rst
@@ -120,23 +120,25 @@ review/understanding of the change.
 Python
 ----------------------------------------
 
-Scripts should be in Python 3 whenever possible.
+Scripts should be in Python whenever possible.
 
 For configure.py (and helper scripts install.py, cleanup.py and build_docs.py)
-the target is stock (no modules outside the standard library) CPython 3.x.
-Support for PyPy, etc is great when viable (in the sense of not causing problems
-for 3.x, and not requiring huge blocks of version dependent code). As running
-this program successfully is required for a working build, making it as portable
-as possible is considered key.
+the target is stock (no modules outside the standard library) CPython 2.7 plus
+latest CPython 3.x. Support for CPython 2.6, PyPy, etc is great when viable (in
+the sense of not causing problems for 2.7 or 3.x, and not requiring huge blocks
+of version dependent code). As running this program successfully is required for
+a working build, making it as portable as possible is considered key.
 
-The python wrapper botan2.py targets CPython 3.x, and latest PyPy. Note that
+The python wrapper botan2.py targets CPython 2.7, 3.x, and latest PyPy. Note that
 a single file is used to avoid dealing with any of Python's various crazy module
 distribution issues.
 
 For random scripts not typically run by an end-user (codegen, visualization, and
-so on) there isn't any need to worry about platform independence. Here it's fine
-to depend on any useful modules such as graphviz or matplotlib, regardless if it
-is available from a stock CPython install.
+so on) there isn't any need to worry about 2.6 and even just running under
+Python2 xor Python3 is acceptable if needed. Here it's fine to depend on any
+useful modules such as graphviz or matplotlib, regardless if it is available
+from a stock CPython install. Since Python2 is now end of life, prefer Python3
+for new scripts of this sort.
 
 Build Tools and Hints
 ----------------------------------------
diff --git a/doc/dev_ref/release_process.rst b/doc/dev_ref/release_process.rst
index ce4139abb..1dd084264 100644
--- a/doc/dev_ref/release_process.rst
+++ b/doc/dev_ref/release_process.rst
@@ -97,7 +97,7 @@ Build The Windows Installer
 
 On Windows, run ``configure.py`` to setup a build::
 
- $ python3 ./configure.py --cc=msvc --cpu=$ARCH --distribution-info=unmodified
+ $ python ./configure.py --cc=msvc --cpu=$ARCH --distribution-info=unmodified
 
 After completing the build (and running the tests), use `InnoSetup
 <http://www.jrsoftware.org/isinfo.php>`_ to create the installer.  A
diff --git a/doc/migration_guide.rst b/doc/migration_guide.rst
index 5302da222..dbdbc301c 100644
--- a/doc/migration_guide.rst
+++ b/doc/migration_guide.rst
@@ -36,6 +36,21 @@ Functionality removed from the TLS implementation includes
 * AES-128 OCB ciphersuites
 * DHE_PSK ciphersuites
 
+TLS 1.3 API adaptions
+---------------------
+
+Sessions
+^^^^^^^^
+
+Old (pre-Botan 3.0) sessions won't load in Botan 3.0 anymore and should be
+discarded.
+
+``Session::session_id()`` is equal to the "session ticket" for TLS 1.3 sessions.
+This ticket might be longer than a typical ID (up to 64kB). If your application
+depends on a short ID for each session, it is safe to just hash the returned
+buffer.
+
+
 Algorithms Removed
 -------------------
 
diff --git a/src/bogo_shim/bogo_shim.cpp b/src/bogo_shim/bogo_shim.cpp
index 869df18b4..5d23aa8e7 100644
--- a/src/bogo_shim/bogo_shim.cpp
+++ b/src/bogo_shim/bogo_shim.cpp
@@ -153,10 +153,13 @@ std::string map_to_bogo_error(const std::string& e)
          { "No shared DTLS version", ":UNSUPPORTED_PROTOCOL:" },
          { "No shared TLS version", ":UNSUPPORTED_PROTOCOL:" },
          { "OS2ECP: Unknown format type 251", ":BAD_ECPOINT:" },
+         { "Peer sent signature algorithm that is not suitable for TLS 1.3", ":WRONG_SIGNATURE_TYPE:" },
          { "Policy forbids all available DTLS version", ":NO_SUPPORTED_VERSIONS_ENABLED:" },
          { "Policy forbids all available TLS version", ":NO_SUPPORTED_VERSIONS_ENABLED:" },
          { "Policy refuses to accept signing with any hash supported by peer", ":NO_COMMON_SIGNATURE_ALGORITHMS:" },
          { "Policy requires client send a certificate, but it did not", ":PEER_DID_NOT_RETURN_A_CERTIFICATE:" },
+         { "PSK identity selected by server is out of bounds", ":PSK_IDENTITY_NOT_FOUND:" },
+         { "PSK and ciphersuite selected by server are not compatible", ":OLD_SESSION_PRF_HASH_MISMATCH:" },
          { "Received a record that exceeds maximum size", ":ENCRYPTED_LENGTH_TOO_LONG:" },
          { "Received an encrypted record that exceeds maximum size", ":ENCRYPTED_LENGTH_TOO_LONG:" },
          { "Received application data after connection closure", ":APPLICATION_DATA_ON_SHUTDOWN:" },
@@ -164,6 +167,7 @@ std::string map_to_bogo_error(const std::string& e)
          { "Received unexpected record version in initial record", ":WRONG_VERSION_NUMBER:" },
          { "Received unexpected record version", ":WRONG_VERSION_NUMBER:" },
          { "Rejecting ALPN request with alert", ":NO_APPLICATION_PROTOCOL:" },
+         { "RSA signatures must use an RSASSA-PSS algorithm", ":WRONG_SIGNATURE_TYPE:" },
          { "Server attempting to negotiate SSLv3 which is not supported", ":UNSUPPORTED_PROTOCOL:" },
          { "Server certificate changed during renegotiation", ":SERVER_CERT_CHANGED:" },
          { "Server changed its mind about extended master secret", ":RENEGOTIATION_EMS_MISMATCH:" },
@@ -197,6 +201,7 @@ std::string map_to_bogo_error(const std::string& e)
          { "Version downgrade received after Hello Retry", ":SECOND_SERVERHELLO_VERSION_MISMATCH:" },
          { "protected change cipher spec received", ":UNEXPECTED_RECORD:" },
          { "Server sent an unsupported extension", ":UNEXPECTED_EXTENSION:" },
+         { "Unsupported extension found in Server Hello", ":UNEXPECTED_EXTENSION:" },
          { "Unexpected extension received", ":UNEXPECTED_EXTENSION:" },
          { "server hello must contain key exchange information", ":MISSING_KEY_SHARE:"},
          { "Peer sent duplicated extensions", ":DUPLICATE_EXTENSION:" },
@@ -761,6 +766,8 @@ std::unique_ptr<Shim_Arguments> parse_options(char* argv[])
       "max-version",
       "min-version",
       "mtu",
+      "on-initial-expect-curve-id",
+      "on-resume-expect-curve-id",
       "port",
       "read-size",
       "resume-count",
diff --git a/src/bogo_shim/config.json b/src/bogo_shim/config.json
index 7111d39c4..071a4a2db 100644
--- a/src/bogo_shim/config.json
+++ b/src/bogo_shim/config.json
@@ -13,7 +13,8 @@
         "HelloRetryRequest-DuplicateCurve-TLS13": "expects 'illegal parameter' but we want to stick with 'decode error'",
         "HelloRetryRequest-DuplicateCookie-TLS13": "expects 'illegal parameter' but we want to stick with 'decode error'",
         "EncryptedExtensionsWithKeyShare-TLS13": "expects 'unsupported extension' but RFC requires 'illegal parameter'",
-        "ClientSkipCertificateVerify-TLS13": "would require ambiguous error mapping"
+        "ClientSkipCertificateVerify-TLS13": "would require ambiguous error mapping",
+        "Resume-Client-Mismatch-TLS13-TLS12-TLS": "server requests a downgrade to TLS 1.2, echoing the random session ID during a TLS 1.3 resumption. => error mapping conflict"
     },
 
     "DisabledTests": {
@@ -41,33 +42,10 @@
         "TooManyChangeCipherSpec-Client-TLS13": "Limits on the number of CCS are not implemented",
         "TooManyKeyUpdates": "Limits on the number of KeyUpdates are not implemented",
 
-        "CertificateVerificationSucceed-Client-TLS13-*" : "TLS 1.3 session resumption is NYI",
-        "Client-VerifyDefault-*-TLS13" : "TLS 1.3 session resumption is NYI",
-        "Client-Verify-*-TLS13" : "TLS 1.3 session resumption is NYI",
-        "ExportKeyingMaterial-TLS13" : "TLS 1.3 session resumption is NYI",
-        "InvalidPSKIdentity-TLS13" : "TLS 1.3 session resumption is NYI",
-        "NegotiatePSKResumption-TLS13" : "TLS 1.3 session resumption is NYI",
-        "Resume-Client-CipherMismatch-TLS13" : "TLS 1.3 session resumption is NYI",
-        "Resume-Client-Mismatch-TLS13-TLS12-TLS" : "TLS 1.3 session resumption is NYI",
-        "Resume-Client-TLS13-TLS13-TLS" : "TLS 1.3 session resumption is NYI",
-        "TLS-TLS13-AES_128_GCM_SHA256-client" : "TLS 1.3 session resumption is NYI",
-        "TLS-TLS13-AES_256_GCM_SHA384-client" : "TLS 1.3 session resumption is NYI",
-        "TLS-TLS13-CHACHA20_POLY1305_SHA256-client" : "TLS 1.3 session resumption is NYI",
-        "TLS12SessionID-TLS13" : "TLS 1.3 session resumption is NYI",
-        "TLS13-HonorServerSessionTicketLifetime" : "TLS 1.3 session resumption is NYI",
-        "TLS13-TestBadTicketAge-Client" : "TLS 1.3 session resumption is NYI",
-        "TLS13-TestValidTicketAge-Client" : "TLS 1.3 session resumption is NYI",
-        "TLS13SessionID-TLS13" : "TLS 1.3 session resumption is NYI",
-        "TolerateServerNameAck-TLS-TLS13" : "TLS 1.3 session resumption is NYI",
-        "OCSPStapling-Client-TLS13-*" : "TLS 1.3 session resumption is NYI",
-        "Resume-Client-NoResume-TLS12-TLS13-TLS": "TLS 1.3 session resumption is NYI",
-        "Resume-Client-Mismatch-TLS12-TLS13-TLS": "TLS 1.3 session resumption is NYI",
-        "Ticket-Forbidden-TLS13": "TLS 1.3 session resumption is NYI",
-        "Resume-Client-PRFMismatch-TLS13": "TLS 1.3 session resumption is NYI",
-        "TLS13-HelloRetryRequest-Client-*": "TLS 1.3 session resumption is NYI",
-        "TLS13-TicketAgeSkew-*": "TLS 1.3 session resumption is NYI",
-        "CurveID-Resume-Client-TLS13": "TLS 1.3 session resumption is NYI",
-        "ALPNClient-TLS-TLS13": "TLS 1.3 session resumption is NYI",
+        "TLS12SessionID-TLS13": "We don't offer TLS 1.3 when a TLS 1.2 session was found",
+        "Ticket-Forbidden-TLS13": "We don't offer TLS 1.3 when a TLS 1.2 session was found",
+        "Resume-Client-NoResume-TLS12-TLS13-TLS": "We don't offer TLS 1.3 when a TLS 1.2 session was found",
+        "Resume-Client-Mismatch-TLS12-TLS13-TLS": "We don't offer TLS 1.3 when a TLS 1.2 session was found",
 
         "KeyUpdate-FromServer": "No TLS 1.3 server, yet",
         "FragmentedClientVersion": "No TLS 1.3 server, yet",
@@ -82,6 +60,7 @@
         "ECDSACurveMismatch-Sign-TLS13": "No TLS 1.3 server, yet",
         "MinimumVersion-Server*-TLS13*": "No TLS 1.3 server, yet",
         "Resume-Server*TLS13*": "No TLS 1.3 server, yet",
+        "Resume-Server-*": "No TLS 1.3 server, yet",
         "Server-Sign-*-TLS13": "No TLS 1.3 server, yet",
         "Server-Verify-*-TLS13": "No TLS 1.3 server, yet",
         "Server-VerifyDefault-*-TLS13": "No TLS 1.3 server, yet",
@@ -110,6 +89,7 @@
         "UnexpectedClientEncryptedExtensions-TLS-TLS13": "No TLS 1.3 server, yet",
         "UnknownCipher-TLS13": "No TLS 1.3 server, yet",
         "VersionTolerance-TLS13": "No TLS 1.3 server, yet",
+        "TLS13-TicketAgeSkew-*": "No TLS 1.3 server, yet",
 
         "*EarlyData*": "No TLS 1.3 Early Data, yet",
         "TLS13-1RTT-Client-*": "No TLS 1.3 Early Data, yet",
@@ -117,7 +97,6 @@
         "SendNoClientCertificateExtensions-TLS13": "-signed-cert-timestamps currently not supported in the shim",
         "KeyUpdate-RequestACK-UnfinishedWrite": "-read-with-unfinished-write currently not supported in the shim",
 
-        "*Binder*": "No TLS 1.3",
         "NoExportEarlyKeyingMaterial*": "No TLS 1.3",
         "EarlyDataEnabled*": "No TLS 1.3",
         "TLS-ECH*": "No ECH support",
diff --git a/src/lib/tls/msg_client_hello.cpp b/src/lib/tls/msg_client_hello.cpp
index b87cfde5b..40342d38a 100644
--- a/src/lib/tls/msg_client_hello.cpp
+++ b/src/lib/tls/msg_client_hello.cpp
@@ -23,6 +23,11 @@
 #include <botan/internal/tls_handshake_io.h>
 #include <botan/internal/tls_handshake_hash.h>
 
+#ifdef BOTAN_HAS_TLS_13
+  #include <botan/internal/tls_transcript_hash_13.h>
+  #include <botan/internal/tls_handshake_layer_13.h>
+#endif
+
 #include <chrono>
 
 namespace Botan::TLS {
@@ -485,7 +490,8 @@ Client_Hello_13::Client_Hello_13(const Policy& policy,
                                  Callbacks& cb,
                                  RandomNumberGenerator& rng,
                                  const std::string& hostname,
-                                 const std::vector<std::string>& next_protocols)
+                                 const std::vector<std::string>& next_protocols,
+                                 const std::optional<Session>& session)
    {
    // RFC 8446 4.1.2
    //    In TLS 1.3, the client indicates its version preferences in the
@@ -522,6 +528,11 @@ Client_Hello_13::Client_Hello_13(const Policy& policy,
 
    m_extensions.add(new Signature_Algorithms(policy.acceptable_signature_schemes()));
 
+   // TODO: Support for PSK-only mode without a key exchange.
+   //       This should be configurable in TLS::Policy and should allow no PSK
+   //       support at all (e.g. to disable support for session resumption).
+   m_extensions.add(new PSK_Key_Exchange_Modes({PSK_Key_Exchange_Mode::PSK_DHE_KE}));
+
    // TODO: Add a signature_algorithms_cert extension negotiating the acceptable
    //       signature algorithms in a server certificate chain's certificates.
 
@@ -552,10 +563,26 @@ Client_Hello_13::Client_Hello_13(const Policy& policy,
          m_extensions.add(new Supported_Point_Formats(policy.use_ecc_point_compression()));
       }
 
+   // TODO: Some extensions require a certain order or pose other assumptions.
+   //       We should check those after the user was allowed to make changes to
+   //       the extensions.
    cb.tls_modify_extensions(m_extensions, CLIENT);
+
+   // RFC 8446 4.2.11
+   //    The "pre_shared_key" extension MUST be the last extension in the
+   //    ClientHello (this facilitates implementation [...]).
+   //
+   // The PSK extension takes the partial transcript hash into account. Passing
+   // into Callbacks::tls_modify_extensions() does not make sense therefore.
+   if(session.has_value())
+      {
+      m_extensions.add(new PSK(session.value(), cb));
+      calculate_psk_binders({});
+      }
    }
 
 void Client_Hello_13::retry(const Hello_Retry_Request& hrr,
+                            const Transcript_Hash_State& transcript_hash_state,
                             Callbacks& cb,
                             RandomNumberGenerator& rng)
    {
@@ -586,6 +613,44 @@ void Client_Hello_13::retry(const Hello_Retry_Request& hrr,
    //       invocations to this callback due to the first Client_Hello or the
    //       retried Client_Hello after receiving a Hello_Retry_Request.
    cb.tls_modify_extensions(m_extensions, CLIENT);
+
+   auto psk = m_extensions.get<PSK>();
+   if(psk)
+      {
+      // Cipher suite should always be a known suite as this is checked upstream
+      const auto cipher = Ciphersuite::by_id(hrr.ciphersuite());
+      BOTAN_ASSERT_NOMSG(cipher.has_value());
+
+      // RFC 8446 4.1.4
+      //    In [...] its updated ClientHello, the client SHOULD NOT offer
+      //    any pre-shared keys associated with a hash other than that of the
+      //    selected cipher suite.
+      psk->filter(cipher.value());
+
+      // RFC 8446 4.2.11.2
+      //    If the server responds with a HelloRetryRequest and the client
+      //    then sends ClientHello2, its binder will be computed over: [...].
+      calculate_psk_binders(transcript_hash_state.clone());
+      }
+   }
+
+void Client_Hello_13::calculate_psk_binders(Transcript_Hash_State ths)
+   {
+   auto psk = m_extensions.get<PSK>();
+   if(!psk || psk->empty())
+      return;
+
+   // RFC 8446 4.2.11.2
+   //    Each entry in the binders list is computed as an HMAC over a
+   //    transcript hash (see Section 4.4.1) containing a partial ClientHello
+   //    [...].
+   //
+   // Therefore we marshal the entire message prematurely to obtain the
+   // (truncated) transcript hash, calculate the PSK binders with it, update
+   // the Client Hello thus finalizing the message. Down the road, it will be
+   // re-marshalled with the correct binders and sent over the wire.
+   Handshake_Layer::prepare_message(*this, ths);
+   psk->calculate_binders(ths);
    }
 
 #endif // BOTAN_HAS_TLS_13
diff --git a/src/lib/tls/msg_server_hello.cpp b/src/lib/tls/msg_server_hello.cpp
index dd9c6bb1b..43d6e087b 100644
--- a/src/lib/tls/msg_server_hello.cpp
+++ b/src/lib/tls/msg_server_hello.cpp
@@ -579,6 +579,7 @@ Server_Hello_13::Server_Hello_13(std::unique_ptr<Server_Hello_Internal> data,
       TLSEXT_KEY_SHARE,
       TLSEXT_PSK_KEY_EXCHANGE_MODES,
       TLSEXT_SUPPORTED_VERSIONS,
+      TLSEXT_PSK,
       };
 
    // As the ServerHello shall only contain essential extensions, we don't give
diff --git a/src/lib/tls/msg_session_ticket.cpp b/src/lib/tls/msg_session_ticket.cpp
index c71f4e64c..d4f1052cd 100644
--- a/src/lib/tls/msg_session_ticket.cpp
+++ b/src/lib/tls/msg_session_ticket.cpp
@@ -11,6 +11,8 @@
 #include <botan/internal/tls_handshake_hash.h>
 #include <botan/internal/loadstor.h>
 
+#include <botan/tls_exceptn.h>
+
 namespace Botan::TLS {
 
 New_Session_Ticket_12::New_Session_Ticket_12(Handshake_IO& io,
@@ -51,14 +53,45 @@ std::vector<uint8_t> New_Session_Ticket_12::serialize() const
 
 #if defined (BOTAN_HAS_TLS_13)
 
-New_Session_Ticket_13::New_Session_Ticket_13(const std::vector<uint8_t>&)
+New_Session_Ticket_13::New_Session_Ticket_13(const std::vector<uint8_t>& buf,
+                                             Connection_Side from)
+   {
+   TLS_Data_Reader reader("New_Session_Ticket_13", buf);
+
+   m_ticket_lifetime_hint = reader.get_uint32_t();
+
+   // RFC 8446 4.6.1
+   //    Servers MUST NOT use any value [of ticket_lifetime] greater than 604800
+   //    seconds (7 days).
+   if(m_ticket_lifetime_hint > 604800)
+      {
+      throw TLS_Exception(Alert::ILLEGAL_PARAMETER,
+                          "Received a session ticket with lifetime longer than one week.");
+      }
+
+   m_ticket_age_add = reader.get_uint32_t();
+   m_ticket_nonce = reader.get_tls_length_value(1);
+   m_ticket = reader.get_tls_length_value(2);
+
+   m_extensions.deserialize(reader, from, type());
+
+   reader.assert_done();
+   }
+
+std::optional<uint32_t> New_Session_Ticket_13::early_data_byte_limit() const
    {
-   // TODO: Implement
+   if(!m_extensions.has<EarlyDataIndication>())
+      return std::nullopt;
+
+   const auto ext = m_extensions.get<EarlyDataIndication>();
+   BOTAN_ASSERT_NOMSG(ext->max_early_data_size().has_value());
+   return ext->max_early_data_size().value();
    }
 
 std::vector<uint8_t> New_Session_Ticket_13::serialize() const
    {
-   return {};
+   // TODO: might be needed once TLS 1.3 server is implemented
+   throw Not_Implemented("serializing New_Session_Ticket_13 is NYI");
    }
 
 #endif
diff --git a/src/lib/tls/tls13/tls_cipher_state.cpp b/src/lib/tls/tls13/tls_cipher_state.cpp
index a73b0505e..164a17e32 100644
--- a/src/lib/tls/tls13/tls_cipher_state.cpp
+++ b/src/lib/tls/tls13/tls_cipher_state.cpp
@@ -16,6 +16,10 @@
  *                                     |
  *                                     +-----> Derive-Secret(., "ext binder" | "res binder", "")
  *                                     |                     = binder_key
+ *                              STATE PSK BINDER
+ * This state is reached by constructing the Cipher_State using init_with_psk().
+ * The state can then be further advanced using advance_with_client_hello() once
+ * the initial Client Hello is fully generated.
  *                                     |
  *                                     +-----> Derive-Secret(., "c e traffic", ClientHello)
  *                                     |                     = client_early_traffic_secret
@@ -27,7 +31,8 @@
  *                                     |
  *                                     *
  *                             STATE EARLY TRAFFIC
- * This state is reached by constructing Cipher_State using init_with_psk() (not yet implemented).
+ * This state is reached by constructing Cipher_State using init_with_psk().
+ * In this state the early data traffic secrets are available. TODO: implement early data.
  * The state can then be further advanced using advance_with_server_hello().
  *                                     *
  *                                     |
@@ -46,9 +51,9 @@
  *                                     |
  *                                     *
  *                          STATE HANDSHAKE TRAFFIC
- * This state is reached by constructing Cipher_State using init_with_server_hello().
- * In this state the handshake traffic secrets are available. The state can then be further
- * advanced using advance_with_server_finished().
+ * This state is reached by constructing Cipher_State using init_with_server_hello() or
+ * advance_with_server_hello(). In this state the handshake traffic secrets are available.
+ * The state can then be further advanced using advance_with_server_finished().
  *                                     *
  *                                     |
  *                                     v
@@ -75,6 +80,8 @@
  *                                                           ClientHello...client Finished)
  *                                                           = resumption_master_secret
  *                             STATE COMPLETED
+ * Once this state is reached the handshake is finished and no further cipher state advances
+ * are possible.
  */
 
 #include <limits>
@@ -113,12 +120,43 @@ std::unique_ptr<Cipher_State> Cipher_State::init_with_server_hello(
    const Ciphersuite& cipher,
    const Transcript_Hash& transcript_hash)
    {
-   auto cs = std::unique_ptr<Cipher_State>(new Cipher_State(side, cipher));
+   auto cs = std::unique_ptr<Cipher_State>(new Cipher_State(side, cipher.prf_algo()));
    cs->advance_without_psk();
-   cs->advance_with_server_hello(std::move(shared_secret), transcript_hash);
+   cs->advance_with_server_hello(cipher, std::move(shared_secret), transcript_hash);
    return cs;
    }
 
+std::unique_ptr<Cipher_State> Cipher_State::init_with_psk(
+         const Connection_Side side,
+         const Cipher_State::PSK_Type type,
+         secure_vector<uint8_t>&& psk,
+         const Ciphersuite& cipher)
+   {
+   auto cs = std::unique_ptr<Cipher_State>(new Cipher_State(side, cipher.prf_algo()));
+   cs->advance_with_psk(type, std::move(psk));
+   return cs;
+   }
+
+void Cipher_State::advance_with_client_hello(const Transcript_Hash &transcript_hash)
+   {
+   BOTAN_ASSERT_NOMSG(m_state == State::PskBinder);
+
+   zap(m_binder_key);
+
+   // TODO: Currently 0-RTT is not yet implemented, hence we don't derive the
+   //       early traffic secret for now.
+   //
+   // const auto client_early_traffic_secret = derive_secret(m_early_secret, "c e traffic", transcript_hash);
+   // derive_write_traffic_key(client_early_traffic_secret);
+
+   m_exporter_master_secret = derive_secret(m_early_secret, "e exp master", transcript_hash);
+
+   m_salt = derive_secret(m_early_secret, "derived", empty_hash());
+   zap(m_early_secret);
+
+   m_state = State::EarlyTraffic;
+   }
+
 void Cipher_State::advance_with_server_finished(const Transcript_Hash& transcript_hash)
    {
    BOTAN_ASSERT_NOMSG(m_state == State::HandshakeTraffic);
@@ -183,6 +221,8 @@ std::vector<uint8_t> Cipher_State::current_nonce(const uint64_t seq_no, const se
 
 uint64_t Cipher_State::encrypt_record_fragment(const std::vector<uint8_t>& header, secure_vector<uint8_t>& fragment)
    {
+   BOTAN_ASSERT_NONNULL(m_encrypt);
+
    m_encrypt->set_key(m_write_key);
    m_encrypt->set_associated_data_vec(header);
    m_encrypt->start(current_nonce(m_write_seq_no, m_write_iv));
@@ -194,6 +234,7 @@ uint64_t Cipher_State::encrypt_record_fragment(const std::vector<uint8_t>& heade
 uint64_t Cipher_State::decrypt_record_fragment(const std::vector<uint8_t>& header,
       secure_vector<uint8_t>& encrypted_fragment)
    {
+   BOTAN_ASSERT_NONNULL(m_decrypt);
    BOTAN_ARG_CHECK(encrypted_fragment.size() >= m_decrypt->minimum_final_size(),
          "fragment too short to decrypt");
 
@@ -208,19 +249,52 @@ uint64_t Cipher_State::decrypt_record_fragment(const std::vector<uint8_t>& heade
 
 size_t Cipher_State::encrypt_output_length(const size_t input_length) const
    {
+   BOTAN_ASSERT_NONNULL(m_encrypt);
    return m_encrypt->output_length(input_length);
    }
 
 size_t Cipher_State::decrypt_output_length(const size_t input_length) const
    {
+   BOTAN_ASSERT_NONNULL(m_decrypt);
    return m_decrypt->output_length(input_length);
    }
 
 size_t Cipher_State::minimum_decryption_input_length() const
    {
+   BOTAN_ASSERT_NONNULL(m_decrypt);
    return m_decrypt->minimum_final_size();
    }
 
+bool Cipher_State::is_compatible_with(const Ciphersuite& cipher) const
+   {
+   if(!cipher.usable_in_version(Protocol_Version::TLS_V13))
+      return false;
+
+   if(m_hash && m_hash->name() != cipher.prf_algo())
+      return false;
+
+   BOTAN_ASSERT_NOMSG((m_encrypt == nullptr) == (m_decrypt == nullptr));
+   // TODO: Find a better way to check that the instantiated cipher algorithm
+   //       is compatible with the one required by the cipher suite.
+   // AEAD_Mode::create() sets defaults the tag length to 16 which is then
+   // reported via AEAD_Mode::name() and hinders the trivial string comparison.
+   if(m_encrypt && m_encrypt->name() != cipher.cipher_algo() &&
+                   m_encrypt->name() != cipher.cipher_algo() + "(16)")
+      return false;
+
+   return true;
+   }
+
+std::vector<uint8_t> Cipher_State::psk_binder_mac(const Transcript_Hash& transcript_hash_with_truncated_client_hello)
+   {
+   BOTAN_ASSERT_NOMSG(m_state == State::PskBinder);
+
+   auto hmac = HMAC(m_hash->new_object());
+   hmac.set_key(m_binder_key);
+   hmac.update(transcript_hash_with_truncated_client_hello);
+   return hmac.final_stdvec();
+   }
+
 std::vector<uint8_t> Cipher_State::finished_mac(const Transcript_Hash& transcript_hash) const
    {
    BOTAN_ASSERT_NOMSG(m_state == State::HandshakeTraffic);
@@ -265,21 +339,19 @@ secure_vector<uint8_t> Cipher_State::export_key(const std::string& label,
 
 namespace {
 
-std::unique_ptr<MessageAuthenticationCode> create_hmac(const Ciphersuite& cipher)
+std::unique_ptr<MessageAuthenticationCode> create_hmac(const std::string& hash)
    {
-   return std::make_unique<HMAC>(HashFunction::create_or_throw(cipher.prf_algo()));
+   return std::make_unique<HMAC>(HashFunction::create_or_throw(hash));
    }
 
 }
 
-Cipher_State::Cipher_State(Connection_Side whoami, const Ciphersuite& cipher)
+Cipher_State::Cipher_State(Connection_Side whoami, const std::string& hash_function)
    : m_state(State::Uninitialized)
    , m_connection_side(whoami)
-   , m_encrypt(AEAD_Mode::create(cipher.cipher_algo(), ENCRYPTION))
-   , m_decrypt(AEAD_Mode::create(cipher.cipher_algo(), DECRYPTION))
-   , m_extract(std::make_unique<HKDF_Extract>(create_hmac(cipher)))
-   , m_expand(std::make_unique<HKDF_Expand>(create_hmac(cipher)))
-   , m_hash(HashFunction::create_or_throw(cipher.prf_algo()))
+   , m_extract(std::make_unique<HKDF_Extract>(create_hmac(hash_function)))
+   , m_expand(std::make_unique<HKDF_Expand>(create_hmac(hash_function)))
+   , m_hash(HashFunction::create_or_throw(hash_function))
    , m_salt(m_hash->output_length(), 0x00)
    , m_write_seq_no(0)
    , m_read_seq_no(0) {}
@@ -290,16 +362,49 @@ void Cipher_State::advance_without_psk()
    {
    BOTAN_ASSERT_NOMSG(m_state == State::Uninitialized);
 
+   // We are not using `m_early_secret` here because the secret won't be needed
+   // in any further state advancement methods.
    const auto early_secret = hkdf_extract(secure_vector<uint8_t>(m_hash->output_length(), 0x00));
    m_salt = derive_secret(early_secret, "derived", empty_hash());
 
+   // Without PSK we skip the `PskBinder` state and go right to `EarlyTraffic`.
    m_state = State::EarlyTraffic;
    }
 
-void Cipher_State::advance_with_server_hello(secure_vector<uint8_t>&& shared_secret,
+void Cipher_State::advance_with_psk(PSK_Type type, secure_vector<uint8_t>&& psk)
+   {
+   BOTAN_ASSERT_NOMSG(m_state == State::Uninitialized);
+
+   m_early_secret = hkdf_extract(std::move(psk));
+
+   const char* binder_label =
+      (type == PSK_Type::RESUMPTION)
+         ? "res binder"
+         : "ext binder";
+
+   // RFC 8446 4.2.11.2
+   //    The PskBinderEntry is computed in the same way as the Finished message
+   //    [...] but with the BaseKey being the binder_key derived via the key
+   //    schedule from the corresponding PSK which is being offered.
+   //
+   // Hence we are doing the binder key derivation and expansion in one go.
+   const auto binder_key = derive_secret(m_early_secret, binder_label, empty_hash());
+   m_binder_key = hkdf_expand_label(binder_key, "finished", {}, m_hash->output_length());
+
+   m_state = State::PskBinder;
+   }
+
+void Cipher_State::advance_with_server_hello(const Ciphersuite& cipher,
+      secure_vector<uint8_t>&& shared_secret,
       const Transcript_Hash& transcript_hash)
    {
    BOTAN_ASSERT_NOMSG(m_state == State::EarlyTraffic);
+   BOTAN_ASSERT_NOMSG(!m_encrypt);
+   BOTAN_ASSERT_NOMSG(!m_decrypt);
+   BOTAN_STATE_CHECK(is_compatible_with(cipher));
+
+   m_encrypt = AEAD_Mode::create_or_throw(cipher.cipher_algo(), ENCRYPTION);
+   m_decrypt = AEAD_Mode::create_or_throw(cipher.cipher_algo(), DECRYPTION);
 
    const auto handshake_secret = hkdf_extract(std::move(shared_secret));
 
@@ -325,6 +430,8 @@ void Cipher_State::advance_with_server_hello(secure_vector<uint8_t>&& shared_sec
 void Cipher_State::derive_write_traffic_key(const secure_vector<uint8_t>& traffic_secret,
       const bool handshake_traffic_secret)
    {
+   BOTAN_ASSERT_NONNULL(m_encrypt);
+
    m_write_key    = hkdf_expand_label(traffic_secret, "key", {}, m_encrypt->minimum_keylength());
    m_write_iv     = hkdf_expand_label(traffic_secret, "iv", {}, NONCE_LENGTH);
    m_write_seq_no = 0;
@@ -340,6 +447,8 @@ void Cipher_State::derive_write_traffic_key(const secure_vector<uint8_t>& traffi
 void Cipher_State::derive_read_traffic_key(const secure_vector<uint8_t>& traffic_secret,
       const bool handshake_traffic_secret)
    {
+   BOTAN_ASSERT_NONNULL(m_encrypt);
+
    m_read_key    = hkdf_expand_label(traffic_secret, "key", {}, m_encrypt->minimum_keylength());
    m_read_iv     = hkdf_expand_label(traffic_secret, "iv", {}, NONCE_LENGTH);
    m_read_seq_no = 0;
diff --git a/src/lib/tls/tls13/tls_cipher_state.h b/src/lib/tls/tls13/tls_cipher_state.h
index 33f005be6..8706d86cc 100644
--- a/src/lib/tls/tls13/tls_cipher_state.h
+++ b/src/lib/tls/tls13/tls_cipher_state.h
@@ -35,7 +35,8 @@ class Ciphersuite;
  * each facilitate certain cryptographic functionality:
  *
  * * init_with_psk()
- *   not yet implemented
+ *   sets up the cipher state with a pre-shared key (out of band or via session
+ *   ticket). will allow sending early data in the future
  *
  * * init_with_server_hello() / advance_with_server_hello()
  *   allows encrypting and decrypting handshake traffic, as well as producing
@@ -58,9 +59,25 @@ class Ciphersuite;
  */
 class BOTAN_TEST_API Cipher_State
    {
+   public:
+      enum class PSK_Type
+         {
+         RESUMPTION,
+         EXTERNAL
+         };
+
    public:
       ~Cipher_State();
 
+      /**
+       * Construct a Cipher_State from a Pre-Shared-Key.
+       */
+      static std::unique_ptr<Cipher_State> init_with_psk(
+         const Connection_Side side,
+         const PSK_Type type,
+         secure_vector<uint8_t>&& psk,
+         const Ciphersuite& cipher);
+
       /**
        * Construct a Cipher_State after receiving a server hello message.
        */
@@ -70,6 +87,19 @@ class BOTAN_TEST_API Cipher_State
          const Ciphersuite& cipher,
          const Transcript_Hash& transcript_hash);
 
+      /**
+       * Transition internal secrets/keys for transporting early application data.
+       * Note that this state transition is legal only for handshakes using PSK.
+       */
+      void advance_with_client_hello(const Transcript_Hash& transcript_hash);
+
+      /**
+       * Transition internal secrets/keys for transporting handshake data.
+       */
+      void advance_with_server_hello(const Ciphersuite& cipher,
+                                     secure_vector<uint8_t>&& shared_secret,
+                                     const Transcript_Hash& transcript_hash);
+
       /**
        * Transition internal secrets/keys for transporting application data.
        */
@@ -115,6 +145,13 @@ class BOTAN_TEST_API Cipher_State
        */
       size_t minimum_decryption_input_length() const;
 
+      /**
+       * Calculates the MAC for a PSK binder value in Client Hellos. Note that
+       * the transcript hash passed into this method is computed from a partial
+       * Client Hello (RFC 8446 4.2.11.2)
+       */
+      std::vector<uint8_t> psk_binder_mac(const Transcript_Hash& transcript_hash_with_truncated_client_hello);
+
       /**
        * Calculate the MAC for a TLS "Finished" handshake message (RFC 8446 4.4.4)
        */
@@ -156,7 +193,9 @@ class BOTAN_TEST_API Cipher_State
        */
       bool can_export_keys() const
          {
-         return (m_state == State::ApplicationTraffic || m_state == State::Completed) &&
+         return (m_state == State::EarlyTraffic       ||
+                 m_state == State::ApplicationTraffic ||
+                 m_state == State::Completed) &&
                 !m_exporter_master_secret.empty();
          }
 
@@ -165,10 +204,21 @@ class BOTAN_TEST_API Cipher_State
        */
       bool can_encrypt_application_traffic() const
          {
+         // TODO: when implementing early traffic (0-RTT) this will likely need
+         //       to allow `State::EarlyTraffic`.
          return m_state != State::Uninitialized && m_state != State::HandshakeTraffic
                 && !m_write_key.empty() && !m_write_iv.empty();
          }
 
+      /**
+       * @returns  true if the selected cipher primitives are compatible with
+       *           the \p cipher suite.
+       *
+       * Note that cipher suites are considered "compatible" as long as the
+       * already selected cipher primitives in this cipher state are compatible.
+       */
+      bool is_compatible_with(const Ciphersuite& cipher) const;
+
       /**
        * Updates the key material used for decrypting data
        * This is triggered after we received a Key_Update from the peer.
@@ -199,16 +249,14 @@ class BOTAN_TEST_API Cipher_State
 
    private:
       /**
-       * @param cipher  the negotiated cipher suite
-       * @param whoami  whether we play the SERVER or CLIENT
+       * @param hash_function  the negotiated hash function to be used
+       * @param whoami         whether we play the SERVER or CLIENT
        */
-      Cipher_State(Connection_Side whoami, const Ciphersuite& cipher);
+      Cipher_State(Connection_Side whoami, const std::string& hash_function);
 
+      void advance_with_psk(PSK_Type type, secure_vector<uint8_t>&& psk);
       void advance_without_psk();
 
-      void advance_with_server_hello(secure_vector<uint8_t>&& shared_secret,
-                                     const Transcript_Hash& transcript_hash);
-
       std::vector<uint8_t> current_nonce(const uint64_t seq_no,
                                          const secure_vector<uint8_t>& iv) const;
 
@@ -245,6 +293,7 @@ class BOTAN_TEST_API Cipher_State
       enum class State
          {
          Uninitialized,
+         PskBinder,
          EarlyTraffic,
          HandshakeTraffic,
          ApplicationTraffic,
@@ -279,6 +328,9 @@ class BOTAN_TEST_API Cipher_State
       secure_vector<uint8_t> m_peer_finished_key;
       secure_vector<uint8_t> m_exporter_master_secret;
       secure_vector<uint8_t> m_resumption_master_secret;
+
+      secure_vector<uint8_t> m_early_secret;
+      secure_vector<uint8_t> m_binder_key;
    };
 
 }
diff --git a/src/lib/tls/tls13/tls_client_impl_13.cpp b/src/lib/tls/tls13/tls_client_impl_13.cpp
index b2af75a2c..5774b4a64 100644
--- a/src/lib/tls/tls13/tls_client_impl_13.cpp
+++ b/src/lib/tls/tls13/tls_client_impl_13.cpp
@@ -31,7 +31,8 @@ Client_Impl_13::Client_Impl_13(Callbacks& callbacks,
                                const std::vector<std::string>& next_protocols) :
    Channel_Impl_13(callbacks, session_manager, creds, rng, policy, false /* is_server */),
    m_info(info),
-   m_should_send_ccs(false)
+   m_should_send_ccs(false),
+   m_resumed_session(find_session_for_resumption())
    {
 #if defined(BOTAN_HAS_TLS_12)
    if(policy.allow_tls12())
@@ -43,7 +44,8 @@ Client_Impl_13::Client_Impl_13(Callbacks& callbacks,
                              callbacks,
                              rng,
                              m_info.hostname(),
-                             next_protocols)));
+                             next_protocols,
+                             m_resumed_session)));
 
    if(expects_downgrade())
       { preserve_client_hello(msg); }
@@ -110,6 +112,29 @@ bool Client_Impl_13::handshake_finished() const
    return m_handshake_state.handshake_finished();
    }
 
+std::optional<Session> Client_Impl_13::find_session_for_resumption()
+   {
+   Session session;
+   if(!session_manager().load_from_server_info(m_info, session))
+      return std::nullopt;
+
+   // Ignore sessions that were not negotiated as TLS 1.3
+   if(session.version().is_pre_tls_13())
+      return std::nullopt;
+
+   // RFC 8446 4.2.11.1
+   //    Clients MUST NOT attempt to use tickets which have ages greater than
+   //    the "ticket_lifetime" value which was provided with the ticket.
+   const auto session_age = callbacks().tls_current_timestamp() - session.start_time();
+   if(session_age > session.lifetime_hint())
+      {
+      session_manager().remove_entry(session.session_id());
+      return std::nullopt;
+      }
+
+   return session;
+   }
+
 void Client_Impl_13::handle(const Server_Hello_12& server_hello_msg)
    {
    if(m_handshake_state.has_hello_retry_request())
@@ -259,23 +284,52 @@ void Client_Impl_13::handle(const Server_Hello_13& sh)
       throw TLS_Exception(Alert::ILLEGAL_PARAMETER, "Server replied using a ciphersuite not allowed in version it offered");
       }
 
+   // RFC 8446 4.2.11
+   //    Clients MUST verify that [...] a server "key_share" extension is present
+   //    if required by the ClientHello "psk_key_exchange_modes" extension.  If
+   //    these values are not consistent, the client MUST abort the handshake
+   //    with an "illegal_parameter" alert.
+   //
+   // Currently, we don't support PSK-only mode, hence a key share extension is
+   // considered mandatory.
+   //
+   // TODO: Implement PSK-only mode.
    if(!sh.extensions().has<Key_Share>())
       {
-      throw Not_Implemented("PSK mode (without key agreement) is NYI");
+      throw TLS_Exception(Alert::ILLEGAL_PARAMETER, "Server Hello did not contain a key share extension");
       }
 
-   // TODO: this is assuming a standard handshake without any PSK mode!
-   BOTAN_ASSERT_NOMSG(ch.extensions().has<Key_Share>());
    auto my_keyshare = ch.extensions().get<Key_Share>();
    auto shared_secret = my_keyshare->exchange(*sh.extensions().get<Key_Share>(), policy(), callbacks(), rng());
    my_keyshare->erase();
 
    m_transcript_hash.set_algorithm(cipher.value().prf_algo());
 
-   m_cipher_state = Cipher_State::init_with_server_hello(m_side,
-                    std::move(shared_secret),
-                    cipher.value(),
-                    m_transcript_hash.current());
+   if(sh.extensions().has<PSK>())
+      {
+      m_cipher_state =
+         ch.extensions().get<PSK>()->select_cipher_state(
+            *sh.extensions().get<PSK>(), cipher.value());
+
+      // TODO: When implementing pure PSK (negotiated out-of-band not as session
+      //       tickets), we might mix resumption and external PSKs in a single
+      //       handshake. In that case we might need to reset `m_resumed_session`
+      //       if the server selected an out-of-band PSK over a resumption PSK!
+
+      // TODO: When implementing early data, `advance_with_client_hello` must
+      //       happen _before_ encrypting any early application data.
+      //       Same when we want to support early key export.
+      m_cipher_state->advance_with_client_hello(m_transcript_hash.previous());
+      m_cipher_state->advance_with_server_hello(cipher.value(), std::move(shared_secret), m_transcript_hash.current());
+      }
+   else
+      {
+      m_resumed_session.reset(); // might have been set if we attempted a resumption
+      m_cipher_state = Cipher_State::init_with_server_hello(m_side,
+                                                            std::move(shared_secret),
+                                                            cipher.value(),
+                                                            m_transcript_hash.current());
+      }
 
    callbacks().tls_examine_extensions(sh.extensions(), SERVER);
 
@@ -309,7 +363,7 @@ void Client_Impl_13::handle(const Hello_Retry_Request& hrr)
    m_transcript_hash = Transcript_Hash_State::recreate_after_hello_retry_request(cipher.value().prf_algo(),
                        m_transcript_hash);
 
-   ch.retry(hrr, callbacks(), rng());
+   ch.retry(hrr, m_transcript_hash, callbacks(), rng());
 
    callbacks().tls_examine_extensions(hrr.extensions(), SERVER);
 
@@ -353,9 +407,11 @@ void Client_Impl_13::handle(const Encrypted_Extensions& encrypted_extensions_msg
 
    callbacks().tls_examine_extensions(encrypted_extensions_msg.extensions(), SERVER);
 
-   bool psk_mode = false;  // TODO
-   if(psk_mode)
+   if(m_handshake_state.server_hello().extensions().has<PSK>())
       {
+      // RFC 8446 2.2
+      //    As the server is authenticating via a PSK, it does not send a
+      //    Certificate or a CertificateVerify message.
       m_transitions.set_expected_next(FINISHED);
       }
    else
@@ -525,7 +581,9 @@ void Client_Impl_13::handle(const Finished_13& finished_msg)
    m_cipher_state->advance_with_server_finished(th_server_finished);
    m_cipher_state->advance_with_client_finished(m_transcript_hash.current());
 
-   // TODO: save session and invoke tls_session_established callback
+   // TODO: Create a dummy session object and invoke tls_session_established.
+   //       Alternatively, consider changing the expectations described in the
+   //       callback's doc string.
 
    // no more handshake messages expected
    m_transitions.set_expected_next({});
@@ -533,10 +591,24 @@ void Client_Impl_13::handle(const Finished_13& finished_msg)
    callbacks().tls_session_activated();
    }
 
-void TLS::Client_Impl_13::handle(const New_Session_Ticket_13&)
+void TLS::Client_Impl_13::handle(const New_Session_Ticket_13& new_session_ticket)
    {
-   // TODO: resumption is not yet implemented, hence we ignore session tickets
-   //       received from the server.
+   Session session(new_session_ticket.ticket(),
+                   m_cipher_state->psk(new_session_ticket.nonce()),
+                   new_session_ticket.early_data_byte_limit(),
+                   new_session_ticket.ticket_age_add(),
+                   new_session_ticket.lifetime_hint(),
+                   m_handshake_state.server_hello().selected_version(),
+                   m_handshake_state.server_hello().ciphersuite(),
+                   Connection_Side::CLIENT,
+                   peer_cert_chain(),
+                   m_info,
+                   callbacks().tls_current_timestamp());
+
+   if(callbacks().tls_session_ticket_received(session))
+      {
+      session_manager().save(session);
+      }
    }
 
 void TLS::Client_Impl_13::handle(const Key_Update& key_update)
@@ -563,8 +635,20 @@ void TLS::Client_Impl_13::handle(const Key_Update& key_update)
 
 std::vector<X509_Certificate> Client_Impl_13::peer_cert_chain() const
    {
-   throw Not_Implemented("peer cert chain is not implemented");
-   return std::vector<X509_Certificate>();
+   std::vector<X509_Certificate> result;
+
+   if(m_handshake_state.has_server_certificate_chain())
+      {
+      const auto& cert_chain = m_handshake_state.server_certificate().cert_chain();
+      std::transform(cert_chain.cbegin(), cert_chain.cend(), std::back_inserter(result),
+                     [](const auto& cert_entry) { return cert_entry.certificate; });
+      }
+   else if(m_resumed_session.has_value())
+      {
+      result = m_resumed_session->peer_certs();
+      }
+
+   return result;
    }
 
 bool Client_Impl_13::prepend_ccs()
diff --git a/src/lib/tls/tls13/tls_client_impl_13.h b/src/lib/tls/tls13/tls_client_impl_13.h
index b1e5adeb0..43061bc23 100644
--- a/src/lib/tls/tls13/tls_client_impl_13.h
+++ b/src/lib/tls/tls13/tls_client_impl_13.h
@@ -83,6 +83,7 @@ class Client_Impl_13 : public Channel_Impl_13
       void handle(const Key_Update& key_update);
 
       void send_client_authentication(Channel_Impl_13::AggregatedMessages& flight);
+      std::optional<Session> find_session_for_resumption();
 
    private:
       const Server_Information m_info;
@@ -90,7 +91,8 @@ class Client_Impl_13 : public Channel_Impl_13
       Client_Handshake_State_13 m_handshake_state;
       Handshake_Transitions m_transitions;
 
-      bool m_should_send_ccs;
+      bool                   m_should_send_ccs;
+      std::optional<Session> m_resumed_session;
    };
 
 }
diff --git a/src/lib/tls/tls13/tls_extensions_key_share.cpp b/src/lib/tls/tls13/tls_extensions_key_share.cpp
index 8431b3aa5..9e0398117 100644
--- a/src/lib/tls/tls13/tls_extensions_key_share.cpp
+++ b/src/lib/tls/tls13/tls_extensions_key_share.cpp
@@ -454,14 +454,6 @@ bool Key_Share::empty() const
    return std::visit([](const auto& key_share) { return key_share.empty(); }, m_impl->key_share);
    }
 
-namespace {
-// This is a helper utility to emulate pattern matching with std::visit.
-// See https://en.cppreference.com/w/cpp/utility/variant/visit for more info.
-template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
-// explicit deduction guide (not needed as of C++20)
-template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
-}
-
 secure_vector<uint8_t> Key_Share::exchange(const Key_Share& peer_keyshare,
       const Policy& policy,
       Callbacks& cb,
diff --git a/src/lib/tls/tls13/tls_extensions_psk.cpp b/src/lib/tls/tls13/tls_extensions_psk.cpp
new file mode 100644
index 000000000..1727a7130
--- /dev/null
+++ b/src/lib/tls/tls13/tls_extensions_psk.cpp
@@ -0,0 +1,279 @@
+/*
+* TLS Extension Pre Shared Key
+* (C) 2022 Jack Lloyd
+*     2022 René Meusel, neXenio GmbH
+*
+* Botan is released under the Simplified BSD License (see license.txt)
+*/
+
+#include <botan/internal/tls_cipher_state.h>
+#include <botan/internal/tls_reader.h>
+#include <botan/internal/stl_util.h>
+#include <botan/tls_callbacks.h>
+#include <botan/tls_exceptn.h>
+#include <botan/tls_extensions.h>
+#include <botan/tls_session.h>
+
+#include <utility>
+
+#if defined(BOTAN_HAS_TLS_13)
+
+namespace Botan::TLS {
+
+namespace {
+
+struct Client_PSK
+   {
+   std::vector<uint8_t> identity;
+   std::vector<uint8_t> binder;
+   uint32_t             obfuscated_ticket_age;
+
+   std::string hash_algorithm;
+   std::unique_ptr<Cipher_State> cipher_state;
+   };
+
+struct Server_PSK
+   {
+   uint16_t selected_identity;
+   };
+
+// RFC 8446 4.2.11.1
+//    The "obfuscated_ticket_age" field of each PskIdentity contains an
+//    obfuscated version of the ticket age formed by taking the age in
+//    milliseconds and adding the "ticket_age_add" value that was included with
+//    the ticket, modulo 2^32.
+uint32_t obfuscate_ticket_age(std::chrono::milliseconds ticket_age, uint32_t ticket_age_add)
+   {
+   const uint64_t age = ticket_age.count();
+   const uint64_t add = ticket_age_add;
+   return static_cast<uint32_t>(age + add);
+   }
+
+}  // namespace
+
+
+class PSK::PSK_Internal
+   {
+   public:
+      PSK_Internal(Server_PSK srv_psk) : psk(std::move(srv_psk)) {}
+      PSK_Internal(std::vector<Client_PSK> clt_psks) : psk(std::move(clt_psks)) {}
+
+      std::variant<std::vector<Client_PSK>, Server_PSK> psk;
+   };
+
+
+PSK::PSK(TLS_Data_Reader& reader,
+         uint16_t extension_size,
+         Handshake_Type message_type)
+   {
+   if(message_type == SERVER_HELLO)
+      {
+      if(extension_size != 2)
+         throw TLS_Exception(Alert::DECODE_ERROR, "Server provided a malformed PSK extension");
+
+      m_impl = std::make_unique<PSK_Internal>(Server_PSK{reader.get_uint16_t()});
+      }
+   else if(message_type == CLIENT_HELLO)
+      {
+      std::vector<Client_PSK> psks;
+
+      const auto identities_length = reader.get_uint16_t();
+      const auto identities_offset = reader.read_so_far();
+
+      while(reader.has_remaining() && (reader.read_so_far() - identities_offset) < identities_length)
+         {
+         auto& psk = psks.emplace_back();
+         psk.identity = reader.get_tls_length_value(2);
+         psk.obfuscated_ticket_age = reader.get_uint32_t();
+         }
+
+      if(reader.read_so_far() - identities_offset != identities_length)
+         {
+         throw TLS_Exception(Alert::DECODE_ERROR, "Inconsistent PSK identity list");
+         }
+
+      const auto binders_length = reader.get_uint16_t();
+      const auto binders_offset = reader.read_so_far();
+
+      for(auto& psk : psks)
+         {
+         if(!reader.has_remaining() || reader.read_so_far() - binders_offset >= binders_length)
+            {
+            throw TLS_Exception(Alert::DECODE_ERROR, "Not enough PSK binders");
+            }
+
+         psk.binder = reader.get_tls_length_value(1);
+         }
+
+      if(reader.read_so_far() - binders_offset != binders_length)
+         {
+         throw TLS_Exception(Alert::DECODE_ERROR, "Inconsistent PSK binders list");
+         }
+
+      m_impl = std::make_unique<PSK_Internal>(std::move(psks));
+      }
+   else
+      {
+      throw TLS_Exception(Alert::DECODE_ERROR, "Found a PSK extension in an unexpected handshake message");
+      }
+   }
+
+
+PSK::PSK(const Session& session_to_resume, Callbacks& callbacks)
+   {
+   std::vector<Client_PSK> psks;
+   auto& cpsk = psks.emplace_back();
+
+   cpsk.identity = session_to_resume.session_ticket();
+
+   const auto age =
+      std::chrono::duration_cast<std::chrono::milliseconds>(
+         callbacks.tls_current_timestamp() - session_to_resume.start_time());
+
+   cpsk.obfuscated_ticket_age =
+      obfuscate_ticket_age(age, session_to_resume.session_age_add());
+
+   auto psk = session_to_resume.master_secret();
+   cpsk.hash_algorithm = session_to_resume.ciphersuite().prf_algo();
+   cpsk.cipher_state = Cipher_State::init_with_psk(CLIENT,
+                                                  Cipher_State::PSK_Type::RESUMPTION,
+                                                  std::move(psk),
+                                                  session_to_resume.ciphersuite());
+
+   // RFC 8446 4.2.11.2
+   //    Each entry in the binders list is computed as an HMAC over a transcript
+   //    hash (see Section 4.4.1) containing a partial ClientHello up to and
+   //    including the PreSharedKeyExtension.identities field.  That is, it
+   //    includes all of the ClientHello but not the binders list itself.  The
+   //    length fields for the message (including the overall length, the length
+   //    of the extensions block, and the length of the "pre_shared_key"
+   //    extension) are all set as if binders of the correct lengths were
+   //    present.
+   //
+   // Hence, we fill the binders with dummy values of the correct length and use
+   // `Client_Hello_13::truncate()` to split them off before calculating the
+   // transcript hash that underpins the PSK binders. S.a. `calculate_binders()`
+   const auto binder_length = HashFunction::create_or_throw(cpsk.hash_algorithm)->output_length();
+   cpsk.binder = std::vector<uint8_t>(binder_length);
+
+   m_impl = std::make_unique<PSK_Internal>(std::move(psks));
+   }
+
+
+PSK::~PSK() = default;
+
+
+bool PSK::empty() const
+   {
+   if(std::holds_alternative<Server_PSK>(m_impl->psk))
+      return false;
+
+   BOTAN_ASSERT_NOMSG(std::holds_alternative<std::vector<Client_PSK>>(m_impl->psk));
+   return std::get<std::vector<Client_PSK>>(m_impl->psk).empty();
+   }
+
+
+std::unique_ptr<Cipher_State> PSK::select_cipher_state(const PSK& server_psk, const Ciphersuite& cipher)
+   {
+   BOTAN_STATE_CHECK(std::holds_alternative<std::vector<Client_PSK>>(m_impl->psk));
+   BOTAN_STATE_CHECK(std::holds_alternative<Server_PSK>(server_psk.m_impl->psk));
+
+   const auto id = std::get<Server_PSK>(server_psk.m_impl->psk).selected_identity;
+   auto& ids = std::get<std::vector<Client_PSK>>(m_impl->psk);
+
+   // RFC 8446 4.2.11
+   //    Clients MUST verify that the server's selected_identity is within the
+   //    range supplied by the client, [...].  If these values are not
+   //    consistent, the client MUST abort the handshake with an
+   //    "illegal_parameter" alert.
+   if(id >= ids.size())
+      {
+      throw TLS_Exception(Alert::ILLEGAL_PARAMETER, "PSK identity selected by server is out of bounds");
+      }
+
+   auto cipher_state = std::exchange(ids[id].cipher_state, nullptr);
+   BOTAN_ASSERT_NONNULL(cipher_state);
+
+   // RFC 8446 4.2.11
+   //    Clients MUST verify that [...] the server selected a cipher suite
+   //    indicating a Hash associated with the PSK [...].  If these values
+   //    are not consistent, the client MUST abort the handshake with an
+   //   "illegal_parameter" alert.
+   if(!cipher_state->is_compatible_with(cipher))
+      {
+      throw TLS_Exception(Alert::ILLEGAL_PARAMETER, "PSK and ciphersuite selected by server are not compatible");
+      }
+
+   // destroy cipher states and PSKs that were not selected by the server
+   ids.clear();
+
+   return cipher_state;
+   }
+
+
+void PSK::filter(const Ciphersuite& cipher)
+   {
+   BOTAN_STATE_CHECK(std::holds_alternative<std::vector<Client_PSK>>(m_impl->psk));
+   auto& psks = std::get<std::vector<Client_PSK>>(m_impl->psk);
+
+   const auto r = std::remove_if(psks.begin(), psks.end(), [&](const auto& psk)
+      { return psk.hash_algorithm != cipher.prf_algo(); });
+   psks.erase(r, psks.end());
+   }
+
+
+std::vector<uint8_t> PSK::serialize(Connection_Side side) const
+   {
+   std::vector<uint8_t> result;
+
+   std::visit(overloaded
+      {
+      [&](const Server_PSK& psk)
+         {
+         BOTAN_STATE_CHECK(side == SERVER);
+         result.reserve(2);
+         result.push_back(get_byte<0>(psk.selected_identity));
+         result.push_back(get_byte<1>(psk.selected_identity));
+         },
+      [&](const std::vector<Client_PSK>& psks)
+         {
+         BOTAN_STATE_CHECK(side == CLIENT);
+
+         std::vector<uint8_t> identities;
+         std::vector<uint8_t> binders;
+         for(const auto& psk : psks)
+            {
+            append_tls_length_value(identities, psk.identity, 2);
+            identities.push_back(get_byte<0>(psk.obfuscated_ticket_age));
+            identities.push_back(get_byte<1>(psk.obfuscated_ticket_age));
+            identities.push_back(get_byte<2>(psk.obfuscated_ticket_age));
+            identities.push_back(get_byte<3>(psk.obfuscated_ticket_age));
+
+            append_tls_length_value(binders, psk.binder, 1);
+            }
+
+         append_tls_length_value(result, identities, 2);
+         append_tls_length_value(result, binders, 2);
+         },
+      },
+      m_impl->psk);
+
+   return result;
+   }
+
+// See RFC 8446 4.2.11.2 for details on how these binders are calculated
+void PSK::calculate_binders(const Transcript_Hash_State& truncated_transcript_hash)
+   {
+   BOTAN_ASSERT_NOMSG(std::holds_alternative<std::vector<Client_PSK>>(m_impl->psk));
+   for(auto& psk : std::get<std::vector<Client_PSK>>(m_impl->psk))
+      {
+      auto tth = truncated_transcript_hash.clone();
+      tth.set_algorithm(psk.hash_algorithm);
+      BOTAN_ASSERT_NONNULL(psk.cipher_state);
+      psk.binder = psk.cipher_state->psk_binder_mac(tth.truncated());
+      }
+   }
+
+}  // Botan::TLS
+
+#endif  // HAS_TLS_13
diff --git a/src/lib/tls/tls13/tls_handshake_layer_13.cpp b/src/lib/tls/tls13/tls_handshake_layer_13.cpp
index f27d59bca..63adb1ea5 100644
--- a/src/lib/tls/tls13/tls_handshake_layer_13.cpp
+++ b/src/lib/tls/tls13/tls_handshake_layer_13.cpp
@@ -110,7 +110,7 @@ std::optional<Msg_Type> parse_message(TLS::TLS_Data_Reader& reader, const Policy
       switch(type)
          {
          case NEW_SESSION_TICKET:
-            return New_Session_Ticket_13(msg);
+            return New_Session_Ticket_13(msg, peer_side);
          case KEY_UPDATE:
             return Key_Update(msg);
          default:
diff --git a/src/lib/tls/tls13/tls_handshake_layer_13.h b/src/lib/tls/tls13/tls_handshake_layer_13.h
index 2431c583a..e573767ae 100644
--- a/src/lib/tls/tls13/tls_handshake_layer_13.h
+++ b/src/lib/tls/tls13/tls_handshake_layer_13.h
@@ -66,7 +66,7 @@ class BOTAN_TEST_API Handshake_Layer
        *
        * @return the marshalled handshake message
        */
-      std::vector<uint8_t> prepare_message(const Handshake_Message_13_Ref message, Transcript_Hash_State& transcript_hash);
+      static std::vector<uint8_t> prepare_message(const Handshake_Message_13_Ref message, Transcript_Hash_State& transcript_hash);
 
       /**
        * Marshalls one post-handshake message for sending in an (encrypted) record.
@@ -75,7 +75,7 @@ class BOTAN_TEST_API Handshake_Layer
        *
        * @return the marshalled post-handshake message
        */
-      std::vector<uint8_t> prepare_post_handshake_message(const Post_Handshake_Message_13& message);
+      static std::vector<uint8_t> prepare_post_handshake_message(const Post_Handshake_Message_13& message);
 
       /**
        * Check if the Handshake_Layer has stored a partial message in its internal buffer.
diff --git a/src/lib/tls/tls13/tls_handshake_state_13.h b/src/lib/tls/tls13/tls_handshake_state_13.h
index 0ccb9d82c..284ac7aae 100644
--- a/src/lib/tls/tls13/tls_handshake_state_13.h
+++ b/src/lib/tls/tls13/tls_handshake_state_13.h
@@ -25,6 +25,7 @@ class BOTAN_TEST_API Handshake_State_13_Base
    {
    public:
       bool has_client_hello() const { return m_client_hello.has_value(); }
+      bool has_server_certificate_chain() const { return m_server_certificate.has_value(); }
       bool has_hello_retry_request() const { return m_hello_retry_request.has_value(); }
       bool has_certificate_request() const { return m_certificate_request.has_value(); }
       bool has_server_finished() const { return m_server_finished.has_value(); }
diff --git a/src/lib/tls/tls13/tls_transcript_hash_13.cpp b/src/lib/tls/tls13/tls_transcript_hash_13.cpp
index a3f82f759..12f196432 100644
--- a/src/lib/tls/tls13/tls_transcript_hash_13.cpp
+++ b/src/lib/tls/tls13/tls_transcript_hash_13.cpp
@@ -8,6 +8,10 @@
 
 #include <botan/internal/tls_transcript_hash_13.h>
 
+#include <botan/internal/tls_reader.h>
+#include <botan/tls_messages.h>
+#include <botan/tls_exceptn.h>
+
 #include <utility>
 
 namespace Botan::TLS {
@@ -22,6 +26,7 @@ Transcript_Hash_State::Transcript_Hash_State(const Transcript_Hash_State& other)
    , m_unprocessed_transcript(other.m_unprocessed_transcript)
    , m_current(other.m_current)
    , m_previous(other.m_previous)
+   , m_truncated(other.m_truncated)
    {}
 
 
@@ -60,15 +65,125 @@ Transcript_Hash_State Transcript_Hash_State::recreate_after_hello_retry_request(
    return ths;
    }
 
+namespace {
+
+// TODO: This is a massive code duplication of the client hello parsing code,
+//       as well as basic parsing of extensions. We should resolve this.
+//
+// Ad-hoc idea: When parsing the production objects, we could keep markers into
+//              the original buffer. E.g. the PSK extensions would keep its off-
+//              set into the entire client hello buffer. Using that offset we
+//              could quickly identify the offset of the binders list slice the
+//              buffer without re-parsing it.
+//
+// Finds the truncation offset in a serialization of Client Hello as defined in
+// RFC 8446 4.2.11.2 used for the calculation of PSK binder MACs.
+size_t find_client_hello_truncation_mark(std::vector<uint8_t> client_hello)
+   {
+   // TODO: C++20: it would be great if TLS_Data_Reader could optionally deal
+   //       with std::span. This would have saved one allocation of std::vector.
+   TLS_Data_Reader reader("Client Hello Truncation", client_hello);
+
+   // handshake message type
+   BOTAN_ASSERT_NOMSG(reader.get_byte() == CLIENT_HELLO);
+
+   // message length
+   reader.discard_next(3);
+
+   // legacy version
+   reader.discard_next(2);
+
+   // random
+   reader.discard_next(32);
+
+   // session ID
+   const auto session_id_length = reader.get_byte();
+   reader.discard_next(session_id_length);
+
+   // TODO: DTLS contains a hello_cookie in this location
+   //       Currently we don't support DTLS 1.3
+
+   // cipher suites
+   const auto ciphersuites_length = reader.get_uint16_t();
+   reader.discard_next(ciphersuites_length);
+
+   // compression methods
+   const auto compression_methods_length = reader.get_byte();
+   reader.discard_next(compression_methods_length);
+
+   // extensions
+   const auto extensions_length = reader.get_uint16_t();
+   const auto extensions_offset = reader.read_so_far();
+   while(reader.has_remaining() && reader.read_so_far() - extensions_offset < extensions_length)
+      {
+      const auto ext_type   = static_cast<Handshake_Extension_Type>(reader.get_uint16_t());
+      const auto ext_length = reader.get_uint16_t();
+
+      // skip over all extensions, finding the PSK extension to be truncated
+      if(ext_type != TLSEXT_PSK)
+         {
+         reader.discard_next(ext_length);
+         continue;
+         }
+
+      // PSK identities list
+      const auto identities_length = reader.get_uint16_t();
+      reader.discard_next(identities_length);
+
+      // check that only the binders are left in the buffer...
+      const auto binders_length = reader.peek_uint16_t();
+      if(binders_length != reader.remaining_bytes() - 2 /* binders_length */)
+         {
+         throw TLS_Exception(Alert::DECODE_ERROR,
+                             "Failed to truncate Client Hello that doesn't end on the PSK binders list");
+         }
+
+      // the reader now points to the truncation point
+      break;
+      }
+
+   // if no PSK extension was found, this will point to the end of the buffer
+   return reader.read_so_far();
+   }
+
+std::vector<uint8_t> read_hash_state(std::unique_ptr<HashFunction>& hash)
+   {
+   // Botan does not support finalizing a HashFunction without resetting
+   // the internal state of the hash. Hence we first copy the internal
+   // state and then finalize the transient HashFunction.
+   return hash->copy_state()->final_stdvec();
+   }
+
+}  // namespace
+
 void Transcript_Hash_State::update(const uint8_t* serialized_message, const size_t serialized_message_length)
    {
    if(m_hash != nullptr)
       {
-      // Botan does not support finalizing a HashFunction without resetting
-      // the internal state of the hash. Hence we first copy the internal
-      // state and then finalize the transient HashFunction.
-      m_hash->update(serialized_message, serialized_message_length);
-      m_previous = std::exchange(m_current, m_hash->copy_state()->final_stdvec());
+      auto truncation_mark = serialized_message_length;
+
+      // Check whether we should generate a truncated hash for supporting PSK
+      // binder calculation or verification. See RFC 8446 4.2.11.2.
+      if(serialized_message_length > 0 && *serialized_message == CLIENT_HELLO)
+         {
+         truncation_mark =
+            find_client_hello_truncation_mark({serialized_message,
+                                               serialized_message + serialized_message_length});
+         }
+
+      if(truncation_mark < serialized_message_length)
+         {
+         m_hash->update(serialized_message, truncation_mark);
+         m_truncated = read_hash_state(m_hash);
+         m_hash->update(serialized_message + truncation_mark, serialized_message_length - truncation_mark);
+         }
+      else
+         {
+         m_truncated.clear();
+         m_hash->update(serialized_message, serialized_message_length);
+         }
+
+      m_previous = std::exchange(m_current, read_hash_state(m_hash));
       }
    else
       {
@@ -88,6 +203,12 @@ const Transcript_Hash& Transcript_Hash_State::previous() const
    return m_previous;
    }
 
+const Transcript_Hash& Transcript_Hash_State::truncated() const
+   {
+   BOTAN_STATE_CHECK(!m_truncated.empty());
+   return m_truncated;
+   }
+
 void Transcript_Hash_State::set_algorithm(const std::string& algo_spec)
    {
    BOTAN_STATE_CHECK(m_hash == nullptr || m_hash->name() == algo_spec);
diff --git a/src/lib/tls/tls13/tls_transcript_hash_13.h b/src/lib/tls/tls13/tls_transcript_hash_13.h
index b20f6b142..db8cc5eb4 100644
--- a/src/lib/tls/tls13/tls_transcript_hash_13.h
+++ b/src/lib/tls/tls13/tls_transcript_hash_13.h
@@ -69,6 +69,18 @@ class BOTAN_TEST_API Transcript_Hash_State
        */
       const Transcript_Hash& previous() const;
 
+      /**
+       * returns a truncated transcript hash (see RFC 8446 4.2.11.2)
+       *
+       * This is useful for implementing PSK binders in the PSK extension of
+       * client hello. It is a transcript over a partially marshalled client
+       * hello message. This hash is available only if the last processed
+       * message was a client hello with a PSK extension.
+       *
+       * throws if no 'truncated' hash is available
+       */
+      const Transcript_Hash& truncated() const;
+
       void set_algorithm(const std::string& algo_spec);
 
       Transcript_Hash_State clone() const;
@@ -85,6 +97,7 @@ class BOTAN_TEST_API Transcript_Hash_State
 
       Transcript_Hash m_current;
       Transcript_Hash m_previous;
+      Transcript_Hash m_truncated;
    };
 
 }
diff --git a/src/lib/tls/tls_callbacks.cpp b/src/lib/tls/tls_callbacks.cpp
index 6edb0f1ff..b2cd49425 100644
--- a/src/lib/tls/tls_callbacks.cpp
+++ b/src/lib/tls/tls_callbacks.cpp
@@ -55,6 +55,15 @@ std::string TLS::Callbacks::tls_decode_group_param(Group_Params group_param)
    return group_param_to_string(group_param);
    }
 
+
+bool TLS::Callbacks::tls_session_ticket_received(const Session& session)
+   {
+   // RFC 8446 4.6.1
+   //    [A ticket_lifetime] of zero indicates that the ticket should be discarded
+   //    immediately.
+   return session.lifetime_hint().count() > 0;
+   }
+
 void TLS::Callbacks::tls_verify_cert_chain(
    const std::vector<X509_Certificate>& cert_chain,
    const std::vector<std::optional<OCSP::Response>>& ocsp_responses,
diff --git a/src/lib/tls/tls_callbacks.h b/src/lib/tls/tls_callbacks.h
index a6fa63025..af32dc4fa 100644
--- a/src/lib/tls/tls_callbacks.h
+++ b/src/lib/tls/tls_callbacks.h
@@ -98,6 +98,21 @@ class BOTAN_PUBLIC_API(2,0) Callbacks
        */
        virtual void tls_session_activated() {}
 
+       /**
+       * Optional callback: New session ticket received
+       * Called when we receive a session ticket from the server at any point
+       * after the initial handshake has finished. Clients may decide to keep or
+       * discard the session ticket in the configured session manager.
+       *
+       * Note: this is called for connections that negotiated TLS 1.3 only.
+       *
+       * @param session the session descriptor
+       *
+       * @return false to prevent the session from being cached, and true to
+       *         cache the session in the configured session manager
+       */
+       virtual bool tls_session_ticket_received(const Session& session);
+
        /**
        * Optional callback with default impl: verify cert chain
        *
diff --git a/src/lib/tls/tls_extensions.cpp b/src/lib/tls/tls_extensions.cpp
index b224e4800..18f99defb 100644
--- a/src/lib/tls/tls_extensions.cpp
+++ b/src/lib/tls/tls_extensions.cpp
@@ -68,6 +68,12 @@ std::unique_ptr<Extension> make_extension(TLS_Data_Reader& reader,
          return std::make_unique<Supported_Versions>(reader, size, from);
 
 #if defined(BOTAN_HAS_TLS_13)
+      case TLSEXT_PSK:
+         return std::make_unique<PSK>(reader, size, message_type);
+
+      case TLSEXT_EARLY_DATA:
+         return std::make_unique<EarlyDataIndication>(reader, size, message_type);
+
       case TLSEXT_COOKIE:
          return std::make_unique<Cookie>(reader, size);
 
@@ -843,5 +849,48 @@ std::vector<uint8_t> Signature_Algorithms_Cert::serialize(Connection_Side whoami
       return m_siganture_algorithms.serialize(whoami);
    }
 
+std::vector<uint8_t> EarlyDataIndication::serialize(Connection_Side) const
+   {
+   std::vector<uint8_t> result;
+   if(m_max_early_data_size.has_value())
+      {
+      const auto max_data = m_max_early_data_size.value();
+      result.push_back(get_byte<0>(max_data));
+      result.push_back(get_byte<1>(max_data));
+      result.push_back(get_byte<2>(max_data));
+      result.push_back(get_byte<3>(max_data));
+      }
+   return result;
+   }
+
+EarlyDataIndication::EarlyDataIndication(TLS_Data_Reader& reader,
+                                         uint16_t extension_size,
+                                         Handshake_Type message_type)
+   {
+   if(message_type == NEW_SESSION_TICKET)
+      {
+      if(extension_size != 4)
+         {
+         throw TLS_Exception(Alert::DECODE_ERROR,
+                             "Received an early_data extension in a NewSessionTicket message "
+                             "without maximum early data size indication");
+         }
+
+      m_max_early_data_size = reader.get_uint32_t();
+      }
+   else if(extension_size != 0)
+      {
+      throw TLS_Exception(Alert::DECODE_ERROR,
+                          "Received an early_data extension containing an unexpected data "
+                          "size indication");
+      }
+   }
+
+bool EarlyDataIndication::empty() const
+   {
+   // This extension may be empty by definition but still carry information
+   return false;
+   }
+
 #endif
 }
diff --git a/src/lib/tls/tls_extensions.h b/src/lib/tls/tls_extensions.h
index a79931769..b21e383ad 100644
--- a/src/lib/tls/tls_extensions.h
+++ b/src/lib/tls/tls_extensions.h
@@ -34,6 +34,10 @@ namespace TLS {
 
 #if defined(BOTAN_HAS_TLS_13)
 class Callbacks;
+class Session;
+class Cipher_State;
+class Ciphersuite;
+class Transcript_Hash_State;
 
 enum class PSK_Key_Exchange_Mode : uint8_t {
    PSK_KE = 0,
@@ -67,6 +71,8 @@ enum Handshake_Extension_Type {
 
    TLSEXT_SUPPORTED_VERSIONS        = 43,
 #if defined(BOTAN_HAS_TLS_13)
+   TLSEXT_PSK                       = 41,
+   TLSEXT_EARLY_DATA                = 42,
    TLSEXT_COOKIE                    = 44,
 
    TLSEXT_PSK_KEY_EXCHANGE_MODES    = 45,
@@ -586,6 +592,50 @@ class BOTAN_UNSTABLE_API Certificate_Authorities final : public Extension
       std::vector<X509_DN> m_distinguished_names;
    };
 
+/**
+ * Pre-Shared Key extension from RFC 8446 4.2.11
+ */
+class BOTAN_UNSTABLE_API PSK final : public Extension
+   {
+   public:
+      static Handshake_Extension_Type static_type() { return TLSEXT_PSK; }
+      Handshake_Extension_Type type() const override { return static_type(); }
+
+      std::vector<uint8_t> serialize(Connection_Side side) const override;
+
+      /**
+       * Returns the cipher state representing the PSK selected by the server.
+       * Note that this destructs the list of offered PSKs and its cipher states
+       * and must therefore not be called more than once.
+       */
+      std::unique_ptr<Cipher_State> select_cipher_state(const PSK& server_psk,
+                                                        const Ciphersuite& cipher);
+
+      /**
+       * Remove PSK identities from the list in \p m_psk that are not compatible
+       * with the passed in \p cipher suite.
+       * This is useful to react to Hello Retry Requests. See RFC 8446 4.1.4.
+       */
+      void filter(const Ciphersuite& cipher);
+
+      bool empty() const override;
+
+      PSK(TLS_Data_Reader& reader, uint16_t extension_size, Handshake_Type message_type);
+
+      PSK(const Session& session_to_resume, Callbacks& callbacks);
+
+      ~PSK();
+
+      void calculate_binders(const Transcript_Hash_State& truncated_transcript_hash);
+
+      // TODO: Implement pure PSK negotiation that is not used for session
+      //       resumption.
+
+   private:
+      class PSK_Internal;
+      std::unique_ptr<PSK_Internal> m_impl;
+   };
+
 /**
 * Signature_Algorithms_Cert from RFC 8446
 */
@@ -658,6 +708,40 @@ class BOTAN_UNSTABLE_API Key_Share final : public Extension
       class Key_Share_Impl;
       std::unique_ptr<Key_Share_Impl> m_impl;
    };
+
+/**
+ * Indicates usage or support of early data as described in RFC 8446 4.2.10.
+ */
+class BOTAN_UNSTABLE_API EarlyDataIndication final : public Extension
+   {
+   public:
+      static Handshake_Extension_Type static_type()
+         { return TLSEXT_EARLY_DATA; }
+
+      Handshake_Extension_Type type() const override { return static_type(); }
+      std::vector<uint8_t> serialize(Connection_Side whoami) const override;
+
+      bool empty() const override;
+
+      std::optional<uint32_t> max_early_data_size() const
+         { return m_max_early_data_size; }
+
+      EarlyDataIndication(TLS_Data_Reader& reader,
+                          uint16_t extension_size,
+                          Handshake_Type message_type);
+
+      /**
+       * The max_early_data_size is exclusively provided by servers when using
+       * this extension in the NewSessionTicket message! Otherwise it stays
+       * std::nullopt and results in an empty extension. (RFC 8446 4.2.10).
+       */
+      EarlyDataIndication(std::optional<uint32_t> max_early_data_size = std::nullopt)
+         : m_max_early_data_size(std::move(max_early_data_size)) {}
+
+   private:
+      std::optional<uint32_t> m_max_early_data_size;
+   };
+
 #endif
 
 /**
diff --git a/src/lib/tls/tls_messages.h b/src/lib/tls/tls_messages.h
index f23e955bd..1c7d18563 100644
--- a/src/lib/tls/tls_messages.h
+++ b/src/lib/tls/tls_messages.h
@@ -220,14 +220,25 @@ class BOTAN_UNSTABLE_API Client_Hello_13 final : public Client_Hello
                       Callbacks& cb,
                       RandomNumberGenerator& rng,
                       const std::string& hostname,
-                      const std::vector<std::string>& next_protocols);
-
+                      const std::vector<std::string>& next_protocols,
+                      const std::optional<Session>& session = std::nullopt);
 
       void retry(const Hello_Retry_Request& hrr,
+                 const Transcript_Hash_State& transcript_hash_state,
                  Callbacks& cb,
                  RandomNumberGenerator& rng);
 
       std::vector<Protocol_Version> supported_versions() const;
+
+   private:
+      /**
+       * If the Client Hello contains a PSK extensions with identities this will
+       * generate the PSK binders as described in RFC 8446 4.2.11.2.
+       * Note that the passed in \p transcript_hash_state might be virgin for
+       * the initial Client Hello and should be primed with ClientHello1 and
+       * HelloRetryRequest for an updated Client Hello.
+       */
+      void calculate_psk_binders(Transcript_Hash_State transcript_hash_state);
    };
 
 #endif // BOTAN_HAS_TLS_13
@@ -866,13 +877,35 @@ class BOTAN_UNSTABLE_API New_Session_Ticket_13 final : public Handshake_Message
    public:
       Handshake_Type type() const override { return NEW_SESSION_TICKET; }
 
-      explicit New_Session_Ticket_13(const std::vector<uint8_t>& buf);
+      New_Session_Ticket_13(const std::vector<uint8_t>& buf,
+                            Connection_Side from);
 
       std::vector<uint8_t> serialize() const override;
 
-   private:
+      const std::vector<uint8_t>& ticket() const { return m_ticket; }
+      const std::vector<uint8_t>& nonce() const { return m_ticket_nonce; }
+      uint32_t ticket_age_add() const { return m_ticket_age_add; }
+      uint32_t lifetime_hint() const { return m_ticket_lifetime_hint; }
+
+      /**
+       * @return  the number of bytes allowed for early data or std::nullopt
+       *          when early data is not allowed at all
+       */
+      std::optional<uint32_t> early_data_byte_limit() const;
 
-      // TODO: implement this message fully
+   private:
+      // RFC 8446 4.6.1
+      //    Clients MUST NOT cache tickets for longer than 7 days, regardless of
+      //    the ticket_lifetime, and MAY delete tickets earlier based on local
+      //    policy.  A server MAY treat a ticket as valid for a shorter period
+      //    of time than what is stated in the ticket_lifetime.
+      //
+      // ... hence we call it 'lifetime hint'.
+      uint32_t m_ticket_lifetime_hint;
+      uint32_t m_ticket_age_add;
+      std::vector<uint8_t> m_ticket_nonce;
+      std::vector<uint8_t> m_ticket;
+      Extensions m_extensions;
    };
 
 #endif
diff --git a/src/lib/tls/tls_session.cpp b/src/lib/tls/tls_session.cpp
index 9f3c8787e..fb99fb737 100644
--- a/src/lib/tls/tls_session.cpp
+++ b/src/lib/tls/tls_session.cpp
@@ -17,6 +17,20 @@
 
 namespace Botan::TLS {
 
+Session::Session() :
+   m_start_time(std::chrono::system_clock::time_point::min()),
+   m_version(),
+   m_ciphersuite(0),
+   m_connection_side(static_cast<Connection_Side>(0)),
+   m_srtp_profile(0),
+   m_extended_master_secret(false),
+   m_encrypt_then_mac(false),
+   m_early_data_allowed(false),
+   m_max_early_data_bytes(0),
+   m_ticket_age_add(0),
+   m_lifetime_hint(0)
+   {}
+
 Session::Session(const std::vector<uint8_t>& session_identifier,
                  const secure_vector<uint8_t>& master_secret,
                  Protocol_Version version,
@@ -40,8 +54,61 @@ Session::Session(const std::vector<uint8_t>& session_identifier,
    m_extended_master_secret(extended_master_secret),
    m_encrypt_then_mac(encrypt_then_mac),
    m_peer_certs(certs),
-   m_server_info(server_info)
+   m_server_info(server_info),
+   m_early_data_allowed(false),
+   m_max_early_data_bytes(0),
+   m_ticket_age_add(0),
+   m_lifetime_hint(0)
    {
+   BOTAN_ARG_CHECK(version.is_pre_tls_13(),
+                   "Instantiated a TLS 1.2 session object with a TLS version newer than 1.2");
+   }
+
+Session::Session(const std::vector<uint8_t>& session_ticket,
+                 const secure_vector<uint8_t>& session_psk,
+                 const std::optional<uint32_t>& max_early_data_bytes,
+                 uint32_t ticket_age_add,
+                 uint32_t lifetime_hint,
+                 Protocol_Version version,
+                 uint16_t ciphersuite,
+                 Connection_Side side,
+                 const std::vector<X509_Certificate>& peer_certs,
+                 const Server_Information& server_info,
+                 std::chrono::system_clock::time_point current_timestamp) :
+   m_start_time(current_timestamp),
+
+   // In TLS 1.3 the PSK and Session Resumption concepts were merged and the
+   // explicit SessionID was retired. Instead an opaque session ticket is used
+   // to identify sessions during resumption. Hence, we deliberately set the
+   // legacy m_identifier as "empty".
+   m_identifier(),
+   m_session_ticket(session_ticket),
+   m_master_secret(session_psk),
+   m_version(version),
+   m_ciphersuite(ciphersuite),
+   m_connection_side(side),
+
+   // TODO: Might become necessary when DTLS 1.3 is being implemented.
+   m_srtp_profile(0),
+
+   // RFC 8446 Appendix D
+   //    Because TLS 1.3 always hashes in the transcript up to the server
+   //    Finished, implementations which support both TLS 1.3 and earlier
+   //    versions SHOULD indicate the use of the Extended Master Secret
+   //    extension in their APIs whenever TLS 1.3 is used.
+   m_extended_master_secret(true),
+
+   // TLS 1.3 uses AEADs, so technically encrypt-then-MAC is not applicable.
+   m_encrypt_then_mac(false),
+   m_peer_certs(peer_certs),
+   m_server_info(server_info),
+   m_early_data_allowed(max_early_data_bytes.has_value()),
+   m_max_early_data_bytes(max_early_data_bytes.value_or(0)),
+   m_ticket_age_add(ticket_age_add),
+   m_lifetime_hint(lifetime_hint)
+   {
+   BOTAN_ARG_CHECK(!version.is_pre_tls_13(),
+                   "Instantiated a TLS 1.3 session object with a TLS version older than 1.3");
    }
 
 Session::Session(const std::string& pem)
@@ -92,6 +159,10 @@ Session::Session(const uint8_t ber[], size_t ber_len)
         .decode(server_port)
         .decode(srp_identifier_str)
         .decode(srtp_profile)
+        .decode(m_early_data_allowed)
+        .decode_integer_type(m_max_early_data_bytes)
+        .decode_integer_type(m_ticket_age_add)
+        .decode_integer_type(m_lifetime_hint)
       .end_cons()
       .verify_end();
 
@@ -169,6 +240,12 @@ secure_vector<uint8_t> Session::DER_encode() const
          .encode(static_cast<size_t>(m_server_info.port()))
          .encode(ASN1_String("", ASN1_Type::Utf8String)) // old srp identifier
          .encode(static_cast<size_t>(m_srtp_profile))
+
+         // the fields below were introduced for TLS 1.3 session tickets
+         .encode(m_early_data_allowed)
+         .encode(static_cast<size_t>(m_max_early_data_bytes))
+         .encode(static_cast<size_t>(m_ticket_age_add))
+         .encode(static_cast<size_t>(m_lifetime_hint))
       .end_cons()
    .get_contents();
    }
@@ -189,6 +266,19 @@ Ciphersuite Session::ciphersuite() const
    return suite.value();
    }
 
+const std::vector<uint8_t>& Session::session_id() const
+   {
+   if(m_version.is_pre_tls_13())
+      return m_identifier;
+
+   // RFC 8446 4.6.1
+   //    ticket:  The value of the ticket to be used as the PSK identity.  The
+   //             ticket itself is an opaque label.  It MAY be either a database
+   //             lookup key or a self-encrypted and self-authenticated value.
+   BOTAN_ASSERT_NOMSG(m_identifier.empty());
+   return m_session_ticket;
+   }
+
 namespace {
 
 // The output length of the HMAC must be a valid keylength for the AEAD
diff --git a/src/lib/tls/tls_session.h b/src/lib/tls/tls_session.h
index 3f5b2a121..8702e7db2 100644
--- a/src/lib/tls/tls_session.h
+++ b/src/lib/tls/tls_session.h
@@ -31,18 +31,10 @@ class BOTAN_PUBLIC_API(2,0) Session final
       /**
       * Uninitialized session
       */
-      Session() :
-         m_start_time(std::chrono::system_clock::time_point::min()),
-         m_version(),
-         m_ciphersuite(0),
-         m_connection_side(static_cast<Connection_Side>(0)),
-         m_srtp_profile(0),
-         m_extended_master_secret(false),
-         m_encrypt_then_mac(false)
-            {}
+      Session();
 
       /**
-      * New session (sets session start time)
+      * New TLS 1.2 session (sets session start time)
       */
       Session(const std::vector<uint8_t>& session_id,
               const secure_vector<uint8_t>& master_secret,
@@ -57,6 +49,21 @@ class BOTAN_PUBLIC_API(2,0) Session final
               uint16_t srtp_profile,
               std::chrono::system_clock::time_point current_timestamp);
 
+      /**
+      * New TLS 1.3 session (sets session start time)
+      */
+      Session(const std::vector<uint8_t>& session_ticket,
+              const secure_vector<uint8_t>& session_psk,
+              const std::optional<uint32_t>& max_early_data_bytes,
+              uint32_t ticket_age_add,
+              uint32_t lifetime_hint,
+              Protocol_Version version,
+              uint16_t ciphersuite,
+              Connection_Side side,
+              const std::vector<X509_Certificate>& peer_certs,
+              const Server_Information& server_info,
+              std::chrono::system_clock::time_point current_timestamp);
+
       /**
       * Load a session from DER representation (created by DER_encode)
       * @param ber DER representation buffer
@@ -140,8 +147,11 @@ class BOTAN_PUBLIC_API(2,0) Session final
 
       /**
       * Get the session identifier
+      *
+      * Note that for TLS 1.3 sessions this will be equal to the session ticket
+      * and might therefore be a up to 2^16-1 bytes long.
       */
-      const std::vector<uint8_t>& session_id() const { return m_identifier; }
+      const std::vector<uint8_t>& session_id() const;
 
       /**
       * Get the negotiated DTLS-SRTP algorithm (RFC 5764)
@@ -152,6 +162,8 @@ class BOTAN_PUBLIC_API(2,0) Session final
 
       bool supports_encrypt_then_mac() const { return m_encrypt_then_mac; }
 
+      bool supports_early_data() const { return m_early_data_allowed; }
+
       /**
       * Return the certificate chain of the peer (possibly empty)
       */
@@ -162,6 +174,16 @@ class BOTAN_PUBLIC_API(2,0) Session final
       */
       std::chrono::system_clock::time_point start_time() const { return m_start_time; }
 
+      /**
+      * Return the ticket obfuscation adder
+      */
+      uint32_t session_age_add() const { return m_ticket_age_add; }
+
+      /**
+      * Return the number of bytes allowed for 0-RTT early data
+      */
+      uint32_t max_early_data_bytes() const { return m_max_early_data_bytes; }
+
       /**
       * Return the session ticket the server gave us
       */
@@ -172,8 +194,25 @@ class BOTAN_PUBLIC_API(2,0) Session final
       */
       const Server_Information& server_info() const { return m_server_info; }
 
+      /**
+      * @return the lifetime of the ticket as defined by the TLS server
+      */
+      std::chrono::seconds lifetime_hint() const { return std::chrono::seconds(m_lifetime_hint); }
+
    private:
-      enum { TLS_SESSION_PARAM_STRUCT_VERSION = 20160812 };
+      // Struct Version history
+      //
+      // 20160812 - Pre TLS 1.3
+      // 20220505 - Introduction of TLS 1.3 sessions
+      //            - added fields:
+      //              - m_early_data_allowed
+      //              - m_max_early_data_bytes
+      //              - m_ticket_age_add
+      //              - m_lifetime_hint
+      enum
+         {
+         TLS_SESSION_PARAM_STRUCT_VERSION = 20220505
+         };
 
       std::chrono::system_clock::time_point m_start_time;
 
@@ -190,6 +229,11 @@ class BOTAN_PUBLIC_API(2,0) Session final
 
       std::vector<X509_Certificate> m_peer_certs;
       Server_Information m_server_info; // optional
+
+      bool m_early_data_allowed;
+      uint32_t m_max_early_data_bytes;
+      uint32_t m_ticket_age_add;
+      uint32_t m_lifetime_hint; // in milliseconds
    };
 
 }
diff --git a/src/lib/utils/stl_util.h b/src/lib/utils/stl_util.h
index 19ebfbc0a..e46b2f196 100644
--- a/src/lib/utils/stl_util.h
+++ b/src/lib/utils/stl_util.h
@@ -141,6 +141,12 @@ constexpr bool holds_any_of(const std::variant<Ts...>& v) noexcept {
     return (std::holds_alternative<Alts>(v) || ...);
 }
 
+// This is a helper utility to emulate pattern matching with std::visit.
+// See https://en.cppreference.com/w/cpp/utility/variant/visit for more info.
+template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
+// explicit deduction guide (not needed as of C++20)
+template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
+
 }
 
 #endif
diff --git a/src/python/botan2.py b/src/python/botan2.py
index 7f66cb630..57e534220 100755
--- a/src/python/botan2.py
+++ b/src/python/botan2.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 """
 Python wrapper of the botan crypto library
@@ -10,7 +10,7 @@ https://botan.randombit.net
 Botan is released under the Simplified BSD License (see license.txt)
 
 This module uses the ctypes module and is usable by programs running
-under at least CPython 3.x, and PyPy
+under at least CPython 2.7, CPython 3.x, and PyPy
 
 It uses botan's ffi module, which exposes a C API. This version of the
 module requires FFI API version 20180713, which was introduced in
@@ -21,7 +21,7 @@ Botan 2.8
 from ctypes import CDLL, POINTER, byref, create_string_buffer, \
     c_void_p, c_size_t, c_uint8, c_uint32, c_uint64, c_int, c_uint, c_char_p
 
-from sys import platform
+from sys import version_info, platform
 from time import strptime, mktime, time as system_time
 from binascii import hexlify
 from datetime import datetime
@@ -504,21 +504,38 @@ def _ctype_str(s):
     if s is None:
         return None
     assert isinstance(s, str)
-    return s.encode('utf-8')
+    if version_info[0] < 3:
+        return s
+    else:
+        return s.encode('utf-8')
 
 def _ctype_to_str(s):
-    return s.decode('utf-8')
+    if version_info[0] < 3:
+        return s.encode('utf-8')
+    else:
+        return s.decode('utf-8')
 
 def _ctype_bits(s):
-    if isinstance(s, bytes):
-        return s
-    elif isinstance(s, str):
-        return s.encode('utf-8')
+    if version_info[0] < 3:
+        if isinstance(s, str):
+            return s
+        elif isinstance(s, unicode): # pylint: disable=undefined-variable
+            return s.decode('utf-8')
+        else:
+            raise Exception("Internal error - unexpected type %s provided to _ctype_bits" % (type(s).__name__))
     else:
-        raise Exception("Internal error - unexpected type %s provided to _ctype_bits" % (type(s).__name__))
+        if isinstance(s, bytes):
+            return s
+        elif isinstance(s, str):
+            return s.encode('utf-8')
+        else:
+            raise Exception("Internal error - unexpected type %s provided to _ctype_bits" % (type(s).__name__))
 
 def _ctype_bufout(buf):
-    return buf.raw
+    if version_info[0] < 3:
+        return str(buf.raw)
+    else:
+        return buf.raw
 
 def _hex_encode(buf):
     return hexlify(buf).decode('ascii')
@@ -555,7 +572,7 @@ def const_time_compare(x, y):
 #
 # RNG
 #
-class RandomNumberGenerator:
+class RandomNumberGenerator(object):
     # Can also use type "system"
     def __init__(self, rng_type='system'):
         self.__obj = c_void_p(0)
@@ -585,7 +602,7 @@ class RandomNumberGenerator:
 #
 # Block cipher
 #
-class BlockCipher:
+class BlockCipher(object):
     def __init__(self, algo):
 
         if isinstance(algo, c_void_p):
@@ -649,7 +666,7 @@ class BlockCipher:
 #
 # Hash function
 #
-class HashFunction:
+class HashFunction(object):
     def __init__(self, algo):
 
         if isinstance(algo, c_void_p):
@@ -693,7 +710,7 @@ class HashFunction:
 #
 # Message authentication codes
 #
-class MsgAuthCode:
+class MsgAuthCode(object):
     def __init__(self, algo):
         flags = c_uint32(0) # always zero in this API version
         self.__obj = c_void_p(0)
@@ -741,7 +758,7 @@ class MsgAuthCode:
         _DLL.botan_mac_final(self.__obj, out)
         return _ctype_bufout(out)
 
-class SymmetricCipher:
+class SymmetricCipher(object):
     def __init__(self, algo, encrypt=True):
         flags = 0 if encrypt else 1
         self.__obj = c_void_p(0)
@@ -915,7 +932,7 @@ def kdf(algo, secret, out_len, salt, label):
 #
 # Public key
 #
-class PublicKey: # pylint: disable=invalid-name
+class PublicKey(object): # pylint: disable=invalid-name
 
     def __init__(self, obj=c_void_p(0)):
         self.__obj = obj
@@ -1037,7 +1054,7 @@ class PublicKey: # pylint: disable=invalid-name
 #
 # Private Key
 #
-class PrivateKey:
+class PrivateKey(object):
 
     def __init__(self, obj=c_void_p(0)):
         self.__obj = obj
@@ -1179,7 +1196,7 @@ class PrivateKey:
         _DLL.botan_privkey_get_field(v.handle_(), self.__obj, _ctype_str(field_name))
         return int(v)
 
-class PKEncrypt:
+class PKEncrypt(object):
     def __init__(self, key, padding):
         self.__obj = c_void_p(0)
         flags = c_uint32(0) # always zero in this ABI
@@ -1196,7 +1213,7 @@ class PKEncrypt:
         return outbuf.raw[0:int(outbuf_sz.value)]
 
 
-class PKDecrypt:
+class PKDecrypt(object):
     def __init__(self, key, padding):
         self.__obj = c_void_p(0)
         flags = c_uint32(0) # always zero in this ABI
@@ -1212,7 +1229,7 @@ class PKDecrypt:
         _DLL.botan_pk_op_decrypt(self.__obj, outbuf, byref(outbuf_sz), _ctype_bits(msg), len(msg))
         return outbuf.raw[0:int(outbuf_sz.value)]
 
-class PKSign: # pylint: disable=invalid-name
+class PKSign(object): # pylint: disable=invalid-name
     def __init__(self, key, padding, der=False):
         self.__obj = c_void_p(0)
         flags = c_uint32(1) if der else c_uint32(0)
@@ -1231,7 +1248,7 @@ class PKSign: # pylint: disable=invalid-name
         _DLL.botan_pk_op_sign_finish(self.__obj, rng_obj.handle_(), outbuf, byref(outbuf_sz))
         return outbuf.raw[0:int(outbuf_sz.value)]
 
-class PKVerify:
+class PKVerify(object):
     def __init__(self, key, padding, der=False):
         self.__obj = c_void_p(0)
         flags = c_uint32(1) if der else c_uint32(0)
@@ -1249,7 +1266,7 @@ class PKVerify:
             return True
         return False
 
-class PKKeyAgreement:
+class PKKeyAgreement(object):
     def __init__(self, key, kdf_name):
         self.__obj = c_void_p(0)
         flags = c_uint32(0) # always zero in this ABI
@@ -1297,7 +1314,7 @@ def _load_buf_or_file(filename, buf, file_fn, buf_fn):
 #
 # X.509 certificates
 #
-class X509Cert: # pylint: disable=invalid-name
+class X509Cert(object): # pylint: disable=invalid-name
     def __init__(self, filename=None, buf=None):
         self.__obj = _load_buf_or_file(filename, buf, _DLL.botan_x509_cert_load_file, _DLL.botan_x509_cert_load)
 
@@ -1476,7 +1493,7 @@ class X509Cert: # pylint: disable=invalid-name
 #
 # X.509 Certificate revocation lists
 #
-class X509CRL:
+class X509CRL(object):
     def __init__(self, filename=None, buf=None):
         self.__obj = _load_buf_or_file(filename, buf, _DLL.botan_x509_crl_load_file, _DLL.botan_x509_crl_load)
 
@@ -1487,7 +1504,7 @@ class X509CRL:
         return self.__obj
 
 
-class MPI: # pylint: disable=too-many-public-methods
+class MPI(object): # pylint: disable=too-many-public-methods
 
     def __init__(self, initial_value=None, radix=None):
 
@@ -1703,7 +1720,7 @@ class MPI: # pylint: disable=too-many-public-methods
     def set_bit(self, bit):
         _DLL.botan_mp_set_bit(self.__obj, c_size_t(bit))
 
-class FormatPreservingEncryptionFE1:
+class FormatPreservingEncryptionFE1(object):
 
     def __init__(self, modulus, key, rounds=5, compat_mode=False):
         flags = c_uint32(1 if compat_mode else 0)
@@ -1723,7 +1740,7 @@ class FormatPreservingEncryptionFE1:
         _DLL.botan_fpe_decrypt(self.__obj, r.handle_(), _ctype_bits(tweak), len(tweak))
         return r
 
-class HOTP:
+class HOTP(object):
     def __init__(self, key, digest="SHA-1", digits=6):
         self.__obj = c_void_p(0)
         _DLL.botan_hotp_init(byref(self.__obj), key, len(key), _ctype_str(digest), digits)
@@ -1744,7 +1761,7 @@ class HOTP:
         else:
             return (False, counter)
 
-class TOTP:
+class TOTP(object):
     def __init__(self, key, digest="SHA-1", digits=6, timestep=30):
         self.__obj = c_void_p(0)
         _DLL.botan_totp_init(byref(self.__obj), key, len(key), _ctype_str(digest), digits, timestep)
@@ -1779,7 +1796,7 @@ def nist_key_unwrap(kek, wrapped):
     _DLL.botan_key_unwrap3394(wrapped, len(wrapped), kek, len(kek), output, byref(out_len))
     return output[0:int(out_len.value)]
 
-class Srp6ServerSession:
+class Srp6ServerSession(object):
     __obj = c_void_p(0)
 
     def __init__(self):
diff --git a/src/scripts/bench.py b/src/scripts/bench.py
index 471bd0f5a..1cc626366 100755
--- a/src/scripts/bench.py
+++ b/src/scripts/bench.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 """
 Compare Botan with OpenSSL using their respective benchmark utils
@@ -28,7 +28,7 @@ def setup_logging(options):
     else:
         log_level = logging.INFO
 
-    class LogOnErrorHandler(logging.StreamHandler):
+    class LogOnErrorHandler(logging.StreamHandler, object):
         def emit(self, record):
             super(LogOnErrorHandler, self).emit(record)
             if record.levelno >= logging.ERROR:
@@ -135,7 +135,7 @@ def run_botan_bench(botan, runtime, buf_sizes, algo):
 
     return output
 
-class BenchmarkResult:
+class BenchmarkResult(object):
     def __init__(self, algo, buf_sizes, openssl_results, botan_results):
         self.algo = algo
         self.results = {}
diff --git a/src/scripts/build_docs.py b/src/scripts/build_docs.py
index c5623ed71..b771db278 100755
--- a/src/scripts/build_docs.py
+++ b/src/scripts/build_docs.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 Botan doc generation script
diff --git a/src/scripts/check.py b/src/scripts/check.py
old mode 100755
new mode 100644
index b8c5882be..ea80d552d
--- a/src/scripts/check.py
+++ b/src/scripts/check.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 Implements the "make check" target
diff --git a/src/scripts/ci_build.py b/src/scripts/ci_build.py
index 0e5d1a3cf..948076010 100755
--- a/src/scripts/ci_build.py
+++ b/src/scripts/ci_build.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 CI build script
@@ -418,6 +418,16 @@ def parse_args(args):
     parser.add_option('--pkcs11-lib', default=os.getenv('PKCS11_LIB'), metavar='LIB',
                       help='Set PKCS11 lib to use for testing')
 
+    parser.add_option('--with-python3', dest='use_python3', action='store_true', default=None,
+                      help='Enable using python3')
+    parser.add_option('--without-python3', dest='use_python3', action='store_false',
+                      help='Disable using python3')
+
+    parser.add_option('--with-pylint3', dest='use_pylint3', action='store_true', default=True,
+                      help='Enable using python3 pylint')
+    parser.add_option('--without-pylint3', dest='use_pylint3', action='store_false',
+                      help='Disable using python3 pylint')
+
     parser.add_option('--disable-werror', action='store_true', default=False,
                       help='Allow warnings to compile')
 
@@ -463,7 +473,15 @@ def main(args=None):
         print("Unknown target '%s'" % (target))
         return 2
 
-    py_interp = 'python3'
+    if options.use_python3 is None:
+        use_python3 = have_prog('python3')
+    else:
+        use_python3 = options.use_python3
+
+    py_interp = 'python'
+    if use_python3:
+        py_interp = 'python3'
+
     if options.cc_bin is None:
         if options.cc == 'gcc':
             options.cc_bin = 'g++'
@@ -501,6 +519,10 @@ def main(args=None):
         pylint_rc = '--rcfile=%s' % (os.path.join(root_dir, 'src/configs/pylint.rc'))
         pylint_flags = [pylint_rc, '--reports=no']
 
+        # Some disabled rules specific to Python3
+        # useless-object-inheritance: complains about code still useful in Python2
+        py3_flags = '--disable=useless-object-inheritance'
+
         py_scripts = [
             'configure.py',
             'src/python/botan2.py',
@@ -521,7 +543,9 @@ def main(args=None):
             'src/editors/sublime/build.py']
 
         full_paths = [os.path.join(root_dir, s) for s in py_scripts]
-        cmds.append([py_interp, '-m', 'pylint'] + pylint_flags + full_paths)
+
+        if use_python3 and options.use_pylint3:
+            cmds.append(['python3', '-m', 'pylint'] + pylint_flags + [py3_flags] + full_paths)
 
     else:
         config_flags, run_test_command, make_prefix, install_prefix = determine_flags(
@@ -593,7 +617,14 @@ def main(args=None):
         python_tests = os.path.join(root_dir, 'src/scripts/test_python.py')
 
         if target in ['shared', 'coverage']:
-            cmds.append([py_interp, '-b', python_tests])
+
+            if options.os == 'windows':
+                if options.cpu == 'x86':
+                    # Python on AppVeyor is a 32-bit binary so only test for 32-bit
+                    cmds.append([py_interp, '-b', python_tests])
+            else:
+                if use_python3:
+                    cmds.append(['python3', '-b', python_tests])
 
         if target in ['shared', 'static', 'bsi', 'nist']:
             cmds.append(make_cmd + ['install'])
diff --git a/src/scripts/ci_check_install.py b/src/scripts/ci_check_install.py
index c07356182..c0a372762 100755
--- a/src/scripts/ci_check_install.py
+++ b/src/scripts/ci_check_install.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 # coding=utf8
 
 """
diff --git a/src/scripts/cleanup.py b/src/scripts/cleanup.py
index aee514367..3e8142bab 100755
--- a/src/scripts/cleanup.py
+++ b/src/scripts/cleanup.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 Implements the "make clean" target
diff --git a/src/scripts/comba.py b/src/scripts/comba.py
index 3c052dbe6..c139b8464 100755
--- a/src/scripts/comba.py
+++ b/src/scripts/comba.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python3
 
 import sys
 import datetime
diff --git a/src/scripts/create_corpus_zip.py b/src/scripts/create_corpus_zip.py
index 96971d316..b749b8d39 100755
--- a/src/scripts/create_corpus_zip.py
+++ b/src/scripts/create_corpus_zip.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 # (C) 2017 Jack Lloyd
 # Botan is released under the Simplified BSD License (see license.txt)
diff --git a/src/scripts/dist.py b/src/scripts/dist.py
index 5a7c5dfd1..de4b1a4bb 100755
--- a/src/scripts/dist.py
+++ b/src/scripts/dist.py
@@ -1,9 +1,9 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 Release script for botan (https://botan.randombit.net/)
 
-This script requires Python 3.6
+This script requires Python 2.7 or 3.6
 
 (C) 2011,2012,2013,2015,2016,2017 Jack Lloyd
 
@@ -299,7 +299,7 @@ def write_archive(version, output_basename, archive_type, rel_epoch, all_files,
     return output_archive
 
 def configure_logging(options):
-    class ExitOnErrorLogHandler(logging.StreamHandler):
+    class ExitOnErrorLogHandler(logging.StreamHandler, object):
         def emit(self, record):
             super(ExitOnErrorLogHandler, self).emit(record)
             # Exit script if and ERROR or worse occurred
diff --git a/src/scripts/ffi_decls.py b/src/scripts/ffi_decls.py
index d7a998259..b336e61f5 100755
--- a/src/scripts/ffi_decls.py
+++ b/src/scripts/ffi_decls.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 """
 Automatically generate declarations for the FFI layer
diff --git a/src/scripts/install.py b/src/scripts/install.py
index 632270905..3e2f095ea 100755
--- a/src/scripts/install.py
+++ b/src/scripts/install.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 Botan install script
diff --git a/src/scripts/macro_checks.py b/src/scripts/macro_checks.py
index 51a4faac1..9b5eaf6f9 100755
--- a/src/scripts/macro_checks.py
+++ b/src/scripts/macro_checks.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 # (C) 2018 Jack Lloyd
 # Botan is released under the Simplified BSD License (see license.txt)
diff --git a/src/scripts/monty.py b/src/scripts/monty.py
index 3f4d170c7..e08b7203c 100755
--- a/src/scripts/monty.py
+++ b/src/scripts/monty.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python3
 
 import sys
 import datetime
diff --git a/src/scripts/oids.py b/src/scripts/oids.py
index 391432f42..dae1a4dc5 100755
--- a/src/scripts/oids.py
+++ b/src/scripts/oids.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 """
 (C) 2016 Jack Lloyd
diff --git a/src/scripts/run_clang_tidy.py b/src/scripts/run_clang_tidy.py
index 2d797b7c2..978815fa9 100755
--- a/src/scripts/run_clang_tidy.py
+++ b/src/scripts/run_clang_tidy.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 import subprocess
 import sys
diff --git a/src/scripts/run_tls_attacker.py b/src/scripts/run_tls_attacker.py
index c2f226ed4..ae05a2176 100755
--- a/src/scripts/run_tls_attacker.py
+++ b/src/scripts/run_tls_attacker.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 # (C) 2017 Jack Lloyd
 # Botan is released under the Simplified BSD License (see license.txt)
diff --git a/src/scripts/run_tls_fuzzer.py b/src/scripts/run_tls_fuzzer.py
index fd0a4e53e..4122e5bd4 100755
--- a/src/scripts/run_tls_fuzzer.py
+++ b/src/scripts/run_tls_fuzzer.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 # (C) 2019 Jack Lloyd
 # Botan is released under the Simplified BSD License (see license.txt)
diff --git a/src/scripts/show_dependencies.py b/src/scripts/show_dependencies.py
index efce1c663..edf2d91e0 100755
--- a/src/scripts/show_dependencies.py
+++ b/src/scripts/show_dependencies.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 Show Botan module dependencies as a list or graph.
diff --git a/src/scripts/tls_scanner/tls_scanner.py b/src/scripts/tls_scanner/tls_scanner.py
index e7e89fda9..3f9aa49d5 100755
--- a/src/scripts/tls_scanner/tls_scanner.py
+++ b/src/scripts/tls_scanner/tls_scanner.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python2
 
 # (C) 2017 Jack Lloyd
 # Botan is released under the Simplified BSD License (see license.txt)
@@ -9,11 +9,11 @@ import subprocess
 import re
 
 def format_report(client_output):
-    version_re = re.compile('TLS (v1\.[0-3]) using ([A-Z0-9_]+)')
+    version_re = re.compile('TLS (v1\.[0-2]) using ([A-Z0-9_]+)')
 
     version_match = version_re.search(client_output)
 
-    #print(client_output)
+    #print client_output
 
     if version_match:
         return "Established %s %s" % (version_match.group(1), version_match.group(2))
@@ -25,7 +25,7 @@ def scanner(args = None):
         args = sys.argv
 
     if len(args) != 2:
-        print("Error: Usage tls_scanner.py host_file")
+        print "Error: Usage tls_scanner.py host_file"
         return 2
 
     scanners = {}
@@ -41,21 +41,21 @@ def scanner(args = None):
     timeout = 10
 
     for url in scanners.keys():
-        print("waiting for", url)
+        print "waiting for", url
 
         for i in range(timeout):
             scanners[url].poll()
             if scanners[url].returncode != None:
                 break
-            #print("Waiting %d more seconds for %s" % (timeout-i, url))
+            #print "Waiting %d more seconds for %s" % (timeout-i, url)
             time.sleep(1)
 
         if scanners[url].returncode != None:
             output = scanners[url].stdout.read() + scanners[url].stderr.read()
-            report[url] = format_report(output.decode("utf-8"))
+            report[url] = format_report(output)
 
     for url in report.keys():
-        print(url, ":", report[url])
+        print url, ":", report[url]
 
     return 0
 
diff --git a/src/scripts/website.py b/src/scripts/website.py
index 8c45f816f..9b745ab7e 100755
--- a/src/scripts/website.py
+++ b/src/scripts/website.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 """
 Generate the Botan website


Test patch:
diff --git a/src/scripts/test_all_configs.py b/src/scripts/test_all_configs.py
index 99db48c37..bc91c6987 100755
--- a/src/scripts/test_all_configs.py
+++ b/src/scripts/test_all_configs.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 """
 This configures and builds with many different sub-configurations
diff --git a/src/scripts/test_cli.py b/src/scripts/test_cli.py
index 2d61a83d3..4f4217b45 100755
--- a/src/scripts/test_cli.py
+++ b/src/scripts/test_cli.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python3
 
 """
 (C) 2018,2019 Jack Lloyd
@@ -33,7 +33,7 @@ def run_socket_tests():
     # Connecting to the server port fails. Possibly a local firewall?
     return platform.system().lower() != "freebsd"
 
-class TestLogHandler(logging.StreamHandler):
+class TestLogHandler(logging.StreamHandler, object):
     def emit(self, record):
         # Do the default stuff first
         super(TestLogHandler, self).emit(record)
@@ -483,10 +483,16 @@ def cli_compress_tests(tmp_dir):
     if not os.access(output_file, os.R_OK):
         logging.error("Compression did not created expected output file")
 
+    is_py3 = sys.version_info[0] == 3
+
     output_hdr = open(output_file, 'rb').read(2)
 
-    if output_hdr[0] != 0x1F or output_hdr[1] != 0x8B:
-        logging.error("Did not see expected gzip header")
+    if is_py3:
+        if output_hdr[0] != 0x1F or output_hdr[1] != 0x8B:
+            logging.error("Did not see expected gzip header")
+    else:
+        if ord(output_hdr[0]) != 0x1F or ord(output_hdr[1]) != 0x8B:
+            logging.error("Did not see expected gzip header")
 
     os.unlink(input_file)
 
@@ -969,7 +975,10 @@ def cli_tls_http_server_tests(tmp_dir):
     if resp.status != 405:
         logging.error('Unexpected response status %d', resp.status)
 
-    rc = tls_server.wait(5)
+    if sys.version_info.major >= 3:
+        rc = tls_server.wait(5) # pylint: disable=too-many-function-args
+    else:
+        rc = tls_server.wait()
 
     if rc != 0:
         logging.error("Unexpected return code from https_server %d", rc)
@@ -1062,7 +1071,10 @@ def cli_tls_proxy_tests(tmp_dir):
         if body != server_response:
             logging.error('Unexpected response from server %s', body)
 
-    rc = tls_proxy.wait(5)
+    if sys.version_info.major >= 3:
+        rc = tls_proxy.wait(5) # pylint: disable=too-many-function-args
+    else:
+        rc = tls_proxy.wait()
 
     if rc != 0:
         logging.error('Unexpected return code %d', rc)
@@ -1075,7 +1087,11 @@ def cli_trust_root_tests(tmp_dir):
 
     dn_re = re.compile('(.+=\".+\")(,.+=\".+\")')
 
-    for line in open(dn_file, encoding='utf8'):
+    encoding_kwords = {}
+    if sys.version_info[0] == 3:
+        encoding_kwords['encoding'] = 'utf8'
+
+    for line in open(dn_file, **encoding_kwords):
         if dn_re.match(line) is None:
             logging.error("Unexpected DN line %s", line)
 
diff --git a/src/scripts/test_cli_crypt.py b/src/scripts/test_cli_crypt.py
index c12ce9a90..e7b9437d6 100755
--- a/src/scripts/test_cli_crypt.py
+++ b/src/scripts/test_cli_crypt.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 (C) 2015,2016,2017,2018 Simon Warta
diff --git a/src/scripts/test_fuzzers.py b/src/scripts/test_fuzzers.py
index 9048cde64..356a83779 100755
--- a/src/scripts/test_fuzzers.py
+++ b/src/scripts/test_fuzzers.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/python
 
 # (C) 2017,2018 Jack Lloyd
 # Botan is released under the Simplified BSD License (see license.txt)
diff --git a/src/scripts/test_python.py b/src/scripts/test_python.py
index 776ddb338..0f65f1c07 100644
--- a/src/scripts/test_python.py
+++ b/src/scripts/test_python.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!/usr/bin/env python
 
 """
 (C) 2015,2017,2018,2019 Jack Lloyd
@@ -8,17 +8,7 @@ Botan is released under the Simplified BSD License (see license.txt)
 
 import unittest
 import binascii
-import os
-import platform
-
-# Starting with Python 3.8 DLL search locations are more restricted on Windows.
-# Hence, we need to explicitly add the current working directory before trying
-# to load the botan python wrapper.
-# See: https://docs.python.org/3/whatsnew/3.8.html#bpo-36085-whatsnew
-if platform.system() == "Windows" and hasattr(os, "add_dll_directory"):
-    os.add_dll_directory(os.getcwd())
-
-import botan2 # pylint: disable=wrong-import-position
+import botan2
 
 def hex_encode(buf):
     return binascii.hexlify(buf).decode('ascii')
diff --git a/src/tests/data/tls_13_rfc8448/transcripts.vec b/src/tests/data/tls_13_rfc8448/transcripts.vec
index b2337431a..f303b00b8 100644
--- a/src/tests/data/tls_13_rfc8448/transcripts.vec
+++ b/src/tests/data/tls_13_rfc8448/transcripts.vec
@@ -6,6 +6,7 @@ ServerHello = 160303005a020000560303a6af06a4121860dc5e6e60249cd34c95930c8ac5cb14
 ServerHandshakeMessages = 17030302a2d1ff334a56f5bff6594a07cc87b580233f500f45e489e7f33af35edf7869fcf40aa40aa2b8ea73f848a7ca07612ef9f945cb960b4068905123ea78b111b429ba9191cd05d2a389280f526134aadc7fc78c4b729df828b5ecf7b13bd9aefb0e57f271585b8ea9bb355c7c79020716cfb9b1183ef3ab20e37d57a6b9d7477609aee6e122a4cf51427325250c7d0e509289444c9b3a648f1d71035d2ed65b0e3cdd0cbae8bf2d0b227812cbb360987255cc744110c453baa4fcd610928d809810e4b7ed1a8fd991f06aa6248204797e36a6a73b70a2559c09ead686945ba246ab66e5edd8044b4c6de3fcf2a89441ac66272fd8fb330ef8190579b3684596c960bd596eea520a56a8d650f563aad27409960dca63d3e688611ea5e22f4415cf9538d51a200c27034272968a264ed6540c84838d89f72c24461aad6d26f59ecaba9acbbb317b66d902f4f292a36ac1b639c637ce343117b659622245317b49eeda0c6258f100d7d961ffb138647e92ea330faeea6dfa31c7a84dc3bd7e1b7a6c7178af36879018e3f252107f243d243dc7339d5684c8b0378bf30244da8c87c843f5e56eb4c5e8280a2b48052cf93b16499a66db7cca71e4599426f7d461e66f99882bd89fc50800becca62d6c74116dbd2972fda1fa80f85df881edbe5a37668936b335583b599186dc5c6918a396fa48a181d6b6fa4f9d62d513afbb992f2b992f67f8afe67f76913fa388cb5630c8ca01e0c65d11c66a1e2ac4c85977b7c7a6999bbf10dc35ae69f5515614636c0b9b68c19ed2e31c0b3b66763038ebba42f3b38edc0399f3a9f23faa63978c317fc9fa66a73f60f0504de93b5b845e275592c12335ee340bbc4fddd502784016e4b3be7ef04dda49f4b440a30cb5d2af939828fd4ae3794e44f94df5a631ede42c1719bfdabf0253fe5175be898e750edc53370d2b
 ClientFinished = 170303003575ec4dc238cce60b298044a71e219c56cc77b0517fe9b93c7a4bfc44d87f38f80338ac98fc46deb384bd1caeacab6867d726c40546
 NewSessionTicket = 17030300de3a6b8f90414a97d6959c3487680de5134a2b240e6cffac116e95d41d6af8f6b580dcf3d11d63c758db289a015940252f55713e061dc13e078891a38efbcf5753ad8ef170ad3c7353d16d9da773b9ca7f2b9fa1b6c0d4a3d03f75e09c30ba1e62972ac46f75f7b981be63439b2999ce13064615139891d5e4c5b406f16e3fc181a77ca475840025db2f0a77f81b5ab05b94c01346755f69232c86519d86cbeeac87aac347d143f9605d64f650db4d023e70e952ca49fe5137121c74bc2697687e248746d6df353005f3bce18696129c8153556b3b6c6779b37bf15985684f
+SessionTicket = 308202d5020401348a5902046274df7202010302010404000481b22c035d829359ee5ff7af4ec900000000262a6494dc486d2c8a34cb33fa90bf1b0070ad3c498883c9367c09a2be785abc55cd226097a3a982117283f82a03a143efd3ff5dd36d64e861be7fd61d2827db279cce145077d454a3664d4e6da4d29ee03725a6a4dafcd0fc67d2aea70529513e3da2677fa5906c5b3f7d8f92f228bda40dda721470f9fbf297b5aea617646fac5c03272e970727c621a79141ef5f7de6505e5bfbc388e93343694093934ae4d357020213010201000201010201000101ff01010004204ecd0eb6ec3b4d87f5d6028f922ca4c5851a277fd41311c9e62d2c9492e1c4f3048201b0308201ac30820115a003020102020102300d06092a864886f70d01010b0500300e310c300a06035504031303727361301e170d3136303733303031323335395a170d3236303733303031323335395a300e310c300a0603550403130372736130819f300d06092a864886f70d010101050003818d0030818902818100b4bb498f8279303d980836399b36c6988c0c68de55e1bdb826d3901a2461eafd2de49a91d015abbc9a95137ace6c1af19eaa6af98c7ced43120998e187a80ee0ccb0524b1b018c3e0b63264d449a6d38e22a5fda430846748030530ef0461c8ca9d9efbfae8ea6d1d03e2bd193eff0ab9a8002c47428a6d35a8d88d79f7f1e3f0203010001a31a301830090603551d1304023000300b0603551d0f0404030205a0300d06092a864886f70d01010b05000381810085aad2a0e5b9276b908c65f73a7267170618a54c5f8a7b337d2df7a594365417f2eae8f8a58c8f8172f9319cf36b7fd6c55b80f21a03015156726096fd335e5e67f2dbf102702e608ccae6bec1fc63a42a99be5c3eb7107c3c54e9b9eb2bd5203b1c3b84e0a8b2f759409ba3eac9d91d402dcc0cc8f8961229ac9187b42b4de10c067365727665720c000201000c000201000101ff02020400020500fad6aac502011e
 Client_AppData = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3031
 Client_AppData_Record = 1703030043a23f7054b62c94d0affafe8228ba55cbefacea42f914aa66bcab3f2b9819a8a5b46b395bd54a9a20441e2b62974e1f5a6292a2977014bd1e3deae63aeebb21694915e4
 Server_AppData = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3031
@@ -13,6 +14,26 @@ Server_AppData_Record = 17030300432e937e11ef4ac740e538ad36005fc4a46932fc3225d05f
 Client_CloseNotify = 1703030013c9872760655666b74d7ff1153efd6db6d0b0e3
 Server_CloseNotify = 1703030013b58fd67166ebf599d24720cfbe7efa7a8864a9
 
+[Resumed_0RTT_Handshake]
+RNG_Pool = 1bc3ceb6bbe39cff938355b5a50adb6db21b7a6af649d7b4bc419d7876487d95bff91188283846dd6a2134ef7180ca2b0b14fb10dce707b5098c0dddc813b2df
+CurrentTimestamp = 1651826546006
+SessionTicket = 308202d5020401348a5902046274df7202010302010404000481b22c035d829359ee5ff7af4ec900000000262a6494dc486d2c8a34cb33fa90bf1b0070ad3c498883c9367c09a2be785abc55cd226097a3a982117283f82a03a143efd3ff5dd36d64e861be7fd61d2827db279cce145077d454a3664d4e6da4d29ee03725a6a4dafcd0fc67d2aea70529513e3da2677fa5906c5b3f7d8f92f228bda40dda721470f9fbf297b5aea617646fac5c03272e970727c621a79141ef5f7de6505e5bfbc388e93343694093934ae4d357020213010201000201010201000101ff01010004204ecd0eb6ec3b4d87f5d6028f922ca4c5851a277fd41311c9e62d2c9492e1c4f3048201b0308201ac30820115a003020102020102300d06092a864886f70d01010b0500300e310c300a06035504031303727361301e170d3136303733303031323335395a170d3236303733303031323335395a300e310c300a0603550403130372736130819f300d06092a864886f70d010101050003818d0030818902818100b4bb498f8279303d980836399b36c6988c0c68de55e1bdb826d3901a2461eafd2de49a91d015abbc9a95137ace6c1af19eaa6af98c7ced43120998e187a80ee0ccb0524b1b018c3e0b63264d449a6d38e22a5fda430846748030530ef0461c8ca9d9efbfae8ea6d1d03e2bd193eff0ab9a8002c47428a6d35a8d88d79f7f1e3f0203010001a31a301830090603551d1304023000300b0603551d0f0404030205a0300d06092a864886f70d01010b05000381810085aad2a0e5b9276b908c65f73a7267170618a54c5f8a7b337d2df7a594365417f2eae8f8a58c8f8172f9319cf36b7fd6c55b80f21a03015156726096fd335e5e67f2dbf102702e608ccae6bec1fc63a42a99be5c3eb7107c3c54e9b9eb2bd5203b1c3b84e0a8b2f759409ba3eac9d91d402dcc0cc8f8961229ac9187b42b4de10c067365727665720c000201000c000201000101ff02020400020500fad6aac502011e
+ClientHello_1 = 1603010200010001fc03031bc3ceb6bbe39cff938355b5a50adb6db21b7a6af649d7b4bc419d7876487d95000006130113031302010001cd0000000b0009000006736572766572ff01000100000a00140012001d00170018001901000101010201030104003300260024001d0020e4ffb68ac05f8d96c99da26698346c6be16482badddafe051a66b4f18d668f0b002a0000002b0003020304000d0020001e040305030603020308040805080604010501060102010402050206020202002d00020101001c0002400100150057000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002900dd00b800b22c035d829359ee5ff7af4ec900000000262a6494dc486d2c8a34cb33fa90bf1b0070ad3c498883c9367c09a2be785abc55cd226097a3a982117283f82a03a143efd3ff5dd36d64e861be7fd61d2827db279cce145077d454a3664d4e6da4d29ee03725a6a4dafcd0fc67d2aea70529513e3da2677fa5906c5b3f7d8f92f228bda40dda721470f9fbf297b5aea617646fac5c03272e970727c621a79141ef5f7de6505e5bfbc388e93343694093934ae4d357fad6aacb0021203add4fb2d8fdf822a0ca3cf7678ef5e88dae990141c5924d57bb6fa31b9e5f9d
+Client_EarlyAppData = 414243444546
+Client_EarlyAppData_Record = 1703030017ab1df420e75c457a7cc5d2844f76d5aee4b4edbf049be0
+ServerHello = 16030300600200005c03033ccfd2dec890222763472ae8136777c9d7358777bb66e91ea5122495f559ea2d00130100003400290002000000330024001d0020121761ee42c333e1b9e77b60dd57c2053cd94512ab47f115e86eff50942cea31002b00020304
+ServerHandshakeMessages = 1703030061dc48237b4b879f50d0d4d262ea8b4716eb40ddc1eb957e11126e8a7149c2d012d37a7115957e64ce30008b9e0323f2c05a9c1c77b4f37849a695ab255060a33fee770ca95cb8486bfd0843b87024865ca35cc41c4e515c64dcb1369f98635bc7a5
+# ClientFinished contains two records:
+#  * end of early data
+#  * client finished
+ClientFinished = 1703030015aca6fc944841298df99593725f9bf9754429b12f09170303003500f8b467d14cf22a4b3f0b6ae0d8e6cc8d08e0db3515ef5c2bdf1922eafbb70009964716d834fb70c3d2a56c5b1f5f6bdba6c333cf
+Client_AppData = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3031
+Client_AppData_Record = 1703030043b1cebce242aa201be9ae5e1cb2a9aa4b33d4e866af1edb068919237741aa031d7a74d491c99b9d4e232b74206bc6fbaa04fe78be44a9b4f54320a17eb76992afac3103
+Server_AppData = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f3031
+Server_AppData_Record = 1703030043275e9f20acff57bc000657d3867df039cccf79047884cf75771746f740b5a83f462a0954c3581393a203a25a7dd14141ef1a37900cdb62ff62dee1ba39ab2590cbf194
+Client_CloseNotify = 17030300130facce3246bdfc6369838d6a82ae6de5d422dc
+Server_CloseNotify = 17030300135b18af444e8e1eec7158fb62d8f2577d37ba5d
+
 [HelloRetryRequest_Handshake]
 RNG_Pool = b0b1c5a5aa37c5919f2ed1d5c6fff7fcb7849716945a2b8cee9258a346677b6f0ed02f8e8117efc75ca7ac32aa7e34eda64cdc0ddad154a5e85289f959f63204ab5473467e19346ceb0a0414e41da21d4d2445bc3025afe97c4e8dc8d513da39
 CurrentTimestamp = 1651826546000
diff --git a/src/tests/test_certstor_system.cpp b/src/tests/test_certstor_system.cpp
index ee42e03f4..663d2064a 100644
--- a/src/tests/test_certstor_system.cpp
+++ b/src/tests/test_certstor_system.cpp
@@ -107,6 +107,12 @@ Test::Result find_cert_by_subject_dn(Botan::Certificate_Store& certstore)
    return result;
    }
 
+/*
+ * test case conditionally disabled for now, see:
+ * https://github.com/randombit/botan/pull/1931
+ * https://github.com/randombit/botan/pull/2280
+*/
+#if !defined(BOTAN_HAS_CERTSTOR_WINDOWS)
 Test::Result find_cert_by_utf8_subject_dn(Botan::Certificate_Store& certstore)
    {
    Test::Result result("System Certificate Store - Find Certificate by UTF8 subject DN");
@@ -133,6 +139,7 @@ Test::Result find_cert_by_utf8_subject_dn(Botan::Certificate_Store& certstore)
 
    return result;
    }
+#endif
 
 Test::Result find_cert_by_subject_dn_and_key_id(Botan::Certificate_Store& certstore)
    {
@@ -359,7 +366,9 @@ class Certstor_System_Tests final : public Test
          results.push_back(find_certs_by_subject_dn_and_key_id(*system));
          results.push_back(find_all_subjects(*system));
          results.push_back(no_certificate_matches(*system));
+#if !defined(BOTAN_HAS_CERTSTOR_WINDOWS)
          results.push_back(find_cert_by_utf8_subject_dn(*system));
+#endif
 #if defined(BOTAN_HAS_CERTSTOR_MACOS)
          results.push_back(certificate_matching_with_dn_normalization(*system));
 #endif
diff --git a/src/tests/test_tls_cipher_state.cpp b/src/tests/test_tls_cipher_state.cpp
index bac94b9fa..891749d2b 100644
--- a/src/tests/test_tls_cipher_state.cpp
+++ b/src/tests/test_tls_cipher_state.cpp
@@ -260,6 +260,15 @@ std::vector<Test::Result> test_secret_derivation_rfc8448_rtt1()
 
    return
       {
+      Botan_Tests::CHECK("ciphersuite compatibility", [&](Test::Result& result)
+         {
+         result.confirm("self-compatibility", cs->is_compatible_with(cipher));
+         result.confirm("fully defined state is not compatible to other suites",
+                        !cs->is_compatible_with(Ciphersuite::from_name("CHACHA20_POLY1305_SHA256").value()) &&
+                        !cs->is_compatible_with(Ciphersuite::from_name("AES_128_CCM_SHA256").value()) &&
+                        !cs->is_compatible_with(Ciphersuite::from_name("PSK_WITH_AES_128_GCM_SHA256").value()));
+         }),
+
       Botan_Tests::CHECK("handshake traffic without PSK (client side)", [&](Test::Result& result)
          {
          result.confirm("can not yet write application data", !cs->can_encrypt_application_traffic());
@@ -338,10 +347,241 @@ std::vector<Test::Result> test_secret_derivation_rfc8448_rtt1()
       };
    }
 
+std::vector<Test::Result> test_secret_derivation_rfc8448_rtt0()
+   {
+   // this is the PSK that was negotiated for session resumption in RFC 8448 (see test case above)
+   const auto psk = Botan::hex_decode_locked(
+      "4e cd 0e b6 ec 3b 4d 87 f5 d6 02 8f 92 2c a4 c5"
+      "85 1a 27 7f d4 13 11 c9 e6 2d 2c 94 92 e1 c4 f3");
+
+   // this shared secret is obtained by a key exchange performed additionally to
+   // the pre-shared-key.
+   const auto shared_secret = Botan::hex_decode_locked(
+      "f4 41 94 75 6f f9 ec 9d 25 18 06 35 d6 6e a6 82"
+      "4c 6a b3 bf 17 99 77 be 37 f7 23 57 0e 7c cb 2e");
+
+   // transcript hash of the client hello up to (including) the PSK modes
+   // (used to calculate the PSK binder)
+   const auto th_client_hello_prefix = Botan::hex_decode(
+      "63 22 4b 2e 45 73 f2 d3 45 4c a8 4b 9d 00 9a 04"
+      "f6 be 9e 05 71 1a 83 96 47 3a ef a0 1e 92 4a 14");
+
+   // transcript hash of the client hello including the PSK identity and binders
+   const auto th_client_hello = Botan::hex_decode(
+      "08 ad 0f a0 5d 7c 72 33 b1 77 5b a2 ff 9f 4c 5b"
+      "8b 59 27 6b 7f 22 7f 13 a9 76 24 5f 5d 96 09 13");
+
+   const auto th_server_hello = Botan::hex_decode(
+      "f7 36 cb 34 fe 25 e7 01 55 1b ee 6f d2 4c 1c c7"
+      "10 2a 7d af 94 05 cb 15 d9 7a af e1 6f 75 7d 03");
+
+   // this is not directly exposed in RFC 8448 but calculated as
+   // SHA-256(ClientHello..EncryptedExtensions)
+   const auto th_pre_server_finished = Botan::hex_decode(
+      "04 05 54 55 ef 74 b2 32 2b a3 66 cb c4 cf e0 27"
+      "23 43 4b 37 b9 b3 67 1b b5 a3 00 60 56 d8 f0 2f");
+
+   const auto th_server_finished = Botan::hex_decode(
+      "b0 ae ff c4 6a 2c fe 33 11 4e 6f d7 d5 1f 9f 04"
+      "b1 ca 3c 49 7d ab 08 93 4a 77 4a 9d 9a d7 db f3");
+
+   // this is not directly exposed in RFC 8448 but calculated as
+   // SHA-256(ClientHello..EndOfEarlyData)
+   const auto th_end_of_early_data = Botan::hex_decode(
+      "9f 23 a0 0c 1f 08 fc 18 80 7b 8b 68 23 7b 56 5d"
+      "f2 0d 4c 50 dd 8e 49 ef 61 2e 1a 5b b1 6c 58 67");
+
+   const auto th_client_finished = Botan::hex_decode(
+      "c3 c1 22 e0 bd 90 7a 4a 3f f6 11 2d 8f d5 3d bf"
+      "89 c7 73 d9 55 2e 8b 6b 9d 56 d3 61 b3 a9 7b f6");
+
+   const auto expected_psk_binder = Botan::hex_decode(
+      "3a dd 4f b2 d8 fd f8 22 a0 ca 3c f7 67 8e f5 e8"
+      "8d ae 99 01 41 c5 92 4d 57 bb 6f a3 1b 9e 5f 9d");
+
+   // this is not part of RFC 8448
+   const std::string export_label   = "export_test_label";
+   const std::string export_context = "rfc8448_psk";
+   const auto expected_key_export = Botan::hex_decode_locked(
+      "b5 89 bc b4 0b 7f 94 d7 6f 2d d6 fc f9 e5 87 8e");
+
+   // this is not part of RFC 8448
+   const std::string early_export_label   = "export_test_label_early";
+   const std::string early_export_context = "rfc8448_psk_early";
+   const auto early_expected_key_export = Botan::hex_decode_locked(
+      "b6 fb 7d 9e b5 4e 97 59 6f e4 ed 93 cc b0 bf 0c");
+
+   // encrypted with server_handshake_traffic_secret
+   const auto encrypted_extensions = RFC8448_TestData
+      (
+      "encrypted_extensions",
+      Botan::hex_decode("17 03 03 00 61"),
+      Botan::hex_decode_locked(
+         "dc 48 23 7b 4b 87 9f"
+         "50 d0 d4 d2 62 ea 8b 47 16 eb 40 dd c1 eb 95 7e 11 12 6e 8a 71"
+         "49 c2 d0 12 d3 7a 71 15 95 7e 64 ce 30 00 8b 9e 03 23 f2 c0 5a"
+         "9c 1c 77 b4 f3 78 49 a6 95 ab 25 50 60 a3 3f ee 77 0c a9 5c b8"
+         "48 6b fd 08 43 b8 70 24 86 5c a3 5c c4 1c 4e 51 5c 64 dc b1 36"
+         "9f 98 63 5b c7 a5"),
+      Botan::hex_decode_locked(
+         "08 00 00 28 00 26 00 0a 00 14 00 12 00 1d 00"
+         "17 00 18 00 19 01 00 01 01 01 02 01 03 01 04 00 1c 00 02 40 01"
+         "00 00 00 00 00 2a 00 00 14 00 00 20 48 d3 e0 e1 b3 d9 07 c6 ac"
+         "ff 14 5e 16 09 03 88 c7 7b 05 c0 50 b6 34 ab 1a 88 bb d0 dd 1a"
+         "34 b2"
+         "16" /* to-be-encrypted content type */)
+      );
+
+   // encrypted with client_handshake_traffic_secret
+   const auto encrypted_client_finished_message = RFC8448_TestData
+      (
+      "encrypted_client_finished_message",
+      Botan::hex_decode("17 03 03 00 35"),
+      Botan::hex_decode_locked("00 f8 b4 67 d1 4c f2"
+         "2a 4b 3f 0b 6a e0 d8 e6 cc 8d 08 e0 db 35 15 ef 5c 2b df 19 22"
+         "ea fb b7 00 09 96 47 16 d8 34 fb 70 c3 d2 a5 6c 5b 1f 5f 6b db"
+         "a6 c3 33 cf"),
+      Botan::hex_decode_locked("14 00 00 20 72 30 a9 c9 52 c2 5c d6 13 8f"
+         "c5 e6 62 83 08 c4 1c 53 35 dd 81 b9 f9 6b ce a5 0f d3 2b da 41 6d"
+         "16" /* to-be-encrypted content type */)
+      );
+
+   // encrypted with client_application_traffic_secret
+   const auto encrypted_application_data_client = RFC8448_TestData
+      (
+      "encrypted_application_data_client",
+      Botan::hex_decode("17 03 03 00 43"),
+      Botan::hex_decode_locked("b1 ce bc e2 42 aa 20"
+         "1b e9 ae 5e 1c b2 a9 aa 4b 33 d4 e8 66 af 1e db 06 89 19 23 77"
+         "41 aa 03 1d 7a 74 d4 91 c9 9b 9d 4e 23 2b 74 20 6b c6 fb aa 04"
+         "fe 78 be 44 a9 b4 f5 43 20 a1 7e b7 69 92 af ac 31 03"),
+      Botan::hex_decode_locked(
+         "00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e"
+         "0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23"
+         "24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31"
+         "17" /* to-be-encrypted content type */)
+      );
+
+   // encrypted with server_application_traffic_secret
+   const auto encrypted_application_data_server = RFC8448_TestData
+      (
+      "encrypted_application_data_server",
+      Botan::hex_decode("17 03 03 00 43"),
+      Botan::hex_decode_locked("27 5e 9f 20 ac ff 57"
+         "bc 00 06 57 d3 86 7d f0 39 cc cf 79 04 78 84 cf 75 77 17 46 f7"
+         "40 b5 a8 3f 46 2a 09 54 c3 58 13 93 a2 03 a2 5a 7d d1 41 41 ef"
+         "1a 37 90 0c db 62 ff 62 de e1 ba 39 ab 25 90 cb f1 94"),
+      Botan::hex_decode_locked(
+         "00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e"
+         "0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23"
+         "24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31"
+         "17" /* to-be-encrypted content type */)
+      );
+
+   auto cipher = Ciphersuite::from_name("AES_128_GCM_SHA256").value();
+
+   auto cs = Cipher_State::init_with_psk(Connection_Side::CLIENT,
+                                         Cipher_State::PSK_Type::RESUMPTION,
+                                         secure_vector<uint8_t>(psk.begin(), psk.end()),
+                                         cipher);
+
+   return
+      {
+      Botan_Tests::CHECK("calculating PSK binder (client side)", [&] (Test::Result& result)
+         {
+         const auto mac = cs->psk_binder_mac(th_client_hello_prefix);
+         result.test_eq("PSK binder is as expected", mac, expected_psk_binder);
+         }),
+
+      Botan_Tests::CHECK("ciphersuite compatibility", [&](Test::Result& result)
+         {
+         result.confirm("self-compatibility", cs->is_compatible_with(cipher));
+         result.confirm("partially defined state is compatible with suites using the same hash",
+                        cs->is_compatible_with(Ciphersuite::from_name("CHACHA20_POLY1305_SHA256").value()) &&
+                        cs->is_compatible_with(Ciphersuite::from_name("AES_128_CCM_SHA256").value()) &&
+                        cs->is_compatible_with(Ciphersuite::from_name("AES_128_CCM_8_SHA256").value()));
+
+         result.confirm("partially defined state is not compatible with other hashes or protocol versions",
+                        !cs->is_compatible_with(Ciphersuite::from_name("PSK_WITH_AES_128_GCM_SHA256").value()) &&
+                        !cs->is_compatible_with(Ciphersuite::from_name("AES_256_GCM_SHA384").value()));
+         }),
+
+      Botan_Tests::CHECK("calculate the early traffic secrets", [&] (Test::Result& result)
+         {
+         cs->advance_with_client_hello(th_client_hello);
+         result.require("early key export is possible", cs->can_export_keys());
+         result.test_eq("early key export produces expected result", cs->export_key(early_export_label, early_export_context, 16), early_expected_key_export);
+
+         // TODO: Once 0-RTT traffic is implemented this will likely allow encrypting
+         //       application traffic in this state.
+         result.confirm("can not yet write application data", !cs->can_encrypt_application_traffic());
+         }),
+
+      Botan_Tests::CHECK("handshake traffic after PSK (client side)", [&](Test::Result& result)
+         {
+         cs->advance_with_server_hello(cipher, secure_vector<uint8_t>(shared_secret), th_server_hello);
+
+         // decrypt encrypted extensions from server
+         encrypted_extensions.decrypt(result, cs.get());
+
+         // TODO: Handling of early traffic is left out as 0-RTT is not implemented yet.
+
+         // validate the MAC we receive in server Finished message
+         const auto expected_server_mac = Botan::hex_decode("48 d3 e0 e1 b3 d9 07 c6 ac ff 14 5e 16 09 03 88"
+                                                            "c7 7b 05 c0 50 b6 34 ab 1a 88 bb d0 dd 1a 34 b2");
+         result.confirm("expecting the correct MAC for server finished", cs->verify_peer_finished_mac(th_pre_server_finished,
+                        expected_server_mac));
+
+         // generate the MAC for the client Finished message
+         const auto expected_client_mac = Botan::hex_decode("72 30 a9 c9 52 c2 5c d6 13 8f c5 e6 62 83 08 c4"
+                                                            "1c 53 35 dd 81 b9 f9 6b ce a5 0f d3 2b da 41 6d");
+         result.test_eq("generating the correct MAC for client finished", cs->finished_mac(th_end_of_early_data), expected_client_mac);
+
+         // encrypt client Finished message by client
+         // (under the client handshake traffic secret)
+         encrypted_client_finished_message.encrypt(result, cs.get());
+         }),
+
+      Botan_Tests::CHECK("application traffic after PSK (client side)", [&](Test::Result& result)
+         {
+         // advance Cipher_State with client_hello...server_Finished
+         // (allows sending of application data)
+         result.test_no_throw("state advancement is legal", [&]
+               {
+               cs->advance_with_server_finished(th_server_finished);
+               });
+
+         result.confirm("can write application data", cs->can_encrypt_application_traffic());
+         result.confirm("can export key material", cs->can_export_keys());
+         result.test_eq("key export produces expected result", cs->export_key(export_label, export_context, 16), expected_key_export);
+
+         // encrypt application data by client
+         encrypted_application_data_client.encrypt(result, cs.get());
+
+         // decrypt application data from server
+         // (encrypted under the application traffic secret -- and a new sequence number)
+         encrypted_application_data_server.decrypt(result, cs.get());
+         }),
+
+      Botan_Tests::CHECK("final state advancement", [&](Test::Result& result)
+         {
+         // advance Cipher_State with client_hello...client_Finished
+         // (allows generation of resumption PSKs)
+         result.test_no_throw("state advancement is legal", [&]
+               {
+               cs->advance_with_client_finished(th_client_finished);
+               });
+
+         result.confirm("can export key material still", cs->can_export_keys());
+         result.test_eq("key export result did not change", cs->export_key(export_label, export_context, 16), expected_key_export);
+         })
+      };
+   }
+
 }  // namespace
 
 namespace Botan_Tests {
-BOTAN_REGISTER_TEST_FN("tls", "tls_cipher_state", test_secret_derivation_rfc8448_rtt1);
+BOTAN_REGISTER_TEST_FN("tls", "tls_cipher_state", test_secret_derivation_rfc8448_rtt1, test_secret_derivation_rfc8448_rtt0);
 }
 
 #endif
diff --git a/src/tests/test_tls_messages.cpp b/src/tests/test_tls_messages.cpp
index da8518ab9..17f569aaa 100644
--- a/src/tests/test_tls_messages.cpp
+++ b/src/tests/test_tls_messages.cpp
@@ -460,6 +460,49 @@ class TLS_13_Message_Parsing_Test final : public Text_Based_Test
 
          Test::Result result("TLS 1.3 " + algo + " parsing");
 
+         if(algo == "client_hello")
+            {
+            try
+               {
+               const std::string extensions = vars.get_req_str("AdditionalData");
+
+               // TODO: When implementing a TLS 1.3 server we will likely switch to
+               //       a Client_Hello::parse() factory method that distinguishes
+               //       TLS 1.2 and TLS 1.3 client hellos while parsing and returns
+               //       a std::variant<> similarly to Server_Hello::parse().
+               //
+               // For now we perform a hard-coded distinction.
+               if(msg_type == "client_hello_13")
+                  {
+                  Botan::TLS::Client_Hello_13 ch(buffer);
+
+                  std::vector<uint8_t> exts_buffer;
+                  for(Botan::TLS::Handshake_Extension_Type const& type : ch.extensions().extension_types())
+                     {
+                     uint16_t u16type = static_cast<uint16_t>(type);
+                     exts_buffer.push_back(Botan::get_byte<0>(u16type));
+                     exts_buffer.push_back(Botan::get_byte<1>(u16type));
+                     }
+                  result.test_eq("Hello extensions", Botan::hex_encode(exts_buffer), extensions);
+
+                  std::vector<uint8_t> ciphersuites_buffer;
+                  for(const auto& cs : ch.ciphersuites())
+                     {
+                     ciphersuites_buffer.push_back(Botan::get_byte<0>(cs));
+                     ciphersuites_buffer.push_back(Botan::get_byte<1>(cs));
+                     }
+                  result.test_eq("Supported ciphersuites", ciphersuites_buffer, ciphersuite);
+                  }
+
+                  result.confirm("this is a positive test that should not have failed yet", is_positive_test);
+               }
+            catch (const std::exception &ex)
+               {
+               result.test_eq("correct error produced", ex.what(), exception);
+               result.confirm("negative test", !is_positive_test);
+               }
+            }
+
          if(algo == "server_hello")
             {
             const std::string extensions = vars.get_req_str("AdditionalData");
@@ -498,7 +541,7 @@ class TLS_13_Message_Parsing_Test final : public Text_Based_Test
             catch(const std::exception &ex)
                {
                result.test_eq("correct error produced", ex.what(), exception);
-               result.require("negative test", !is_positive_test);
+               result.confirm("negative test", !is_positive_test);
                }
             }
 
diff --git a/src/tests/test_tls_rfc8448.cpp b/src/tests/test_tls_rfc8448.cpp
index 6d91a7198..9cc60cc3b 100644
--- a/src/tests/test_tls_rfc8448.cpp
+++ b/src/tests/test_tls_rfc8448.cpp
@@ -27,6 +27,7 @@
    #include <botan/auto_rng.h>  // TODO: replace me, otherwise we depend on auto_rng module
    #include <botan/credentials_manager.h>
    #include <botan/rsa.h>
+   #include <botan/hash.h>
    #include <botan/tls_alert.h>
    #include <botan/tls_callbacks.h>
    #include <botan/tls_client.h>
@@ -188,6 +189,12 @@ class Test_TLS_13_Callbacks : public Botan::TLS::Callbacks
          session_activated_called = true;
          }
 
+      bool tls_session_ticket_received(const Session&) override
+         {
+         count_callback_invocation("tls_session_ticket_received");
+         return true; // should always store the session
+         }
+
       void tls_verify_cert_chain(
          const std::vector<Botan::X509_Certificate>& cert_chain,
          const std::vector<std::optional<Botan::OCSP::Response>>&,
@@ -411,6 +418,92 @@ class RFC8448_Text_Policy : public Botan::TLS::Text_Policy
          }
    };
 
+/**
+ * In-Memory Session Manager that stores sessions verbatim, without encryption.
+ * Therefor it is not dependent on a random number generator and can easily be
+ * instrumented for test inspection.
+ */
+class RFC8448_Session_Manager : public Botan::TLS::Session_Manager
+   {
+   private:
+      template<typename K, typename M>
+      bool load(const K& key, const M& map, Session& session)
+         {
+         const auto session_itr = map.find(key);
+         if(session_itr == map.end())
+            return false;
+
+         const auto& der = session_itr->second;
+         session = Session(der.data(), der.size());
+         return true;
+         }
+
+   public:
+      std::vector<std::vector<uint8_t>> all_sessions() const
+         {
+         std::vector<std::vector<uint8_t>> sessions;
+         std::transform(m_sessions_by_si.cbegin(), m_sessions_by_si.cend(), std::back_inserter(sessions),
+                        [](const auto& session) { return session.second; });
+         return sessions;
+         }
+
+      bool load_from_session_id(const std::vector<uint8_t>& session_id,
+                                Session& session) override
+         {
+         return load(id(session_id), m_sessions_by_sid, session);
+         }
+
+      bool load_from_server_info(const Server_Information& info,
+                                 Session& session) override
+         {
+         return load(info, m_sessions_by_si, session);
+         }
+
+      void remove_entry(const std::vector<uint8_t>& session_id) override
+         {
+         Session s;
+         const auto session_found = load_from_session_id(session_id, s);
+         if(!session_found)
+            return;
+
+         const auto sid = id(session_id);
+         m_sessions_by_sid.erase(sid);
+         m_sessions_by_si.erase(s.server_info());
+         }
+
+      size_t remove_all() override
+         {
+         const auto sessions = m_sessions_by_sid.size();
+         m_sessions_by_sid.clear();
+         m_sessions_by_si.clear();
+         return sessions;
+         }
+
+      void save(const Session& session) override
+         {
+         const auto sid = id(session.session_id());
+         const auto der = unlock(session.DER_encode());
+         m_sessions_by_sid[sid] = der;
+         m_sessions_by_si[session.server_info()] = der;
+         }
+
+      std::chrono::seconds session_lifetime() const override
+         {
+         return std::chrono::seconds(42);
+         }
+
+   private:
+      std::string id(const std::vector<uint8_t>& session_id) const
+         {
+         auto h = HashFunction::create_or_throw("SHA-256");
+         return Botan::hex_encode(h->process(session_id));
+         }
+
+   private:
+      std::map<std::string, std::vector<uint8_t>> m_sessions_by_sid;
+      std::map<Server_Information, std::vector<uint8_t>> m_sessions_by_si;
+   };
+
 /**
  * This steers the TLS client handle and is the central entry point for the
  * test cases to interact with the TLS 1.3 implementation.
@@ -424,12 +517,17 @@ class TLS_Context
                   RFC8448_Text_Policy policy,
                   Modify_Exts_Fn modify_exts_cb,
                   MockSignature_Fn mock_signature_cb,
-                  uint64_t timestamp)
+                  uint64_t timestamp,
+                  std::optional<std::vector<uint8_t>> session)
          : m_callbacks(std::move(modify_exts_cb), std::move(mock_signature_cb), timestamp)
          , m_rng(std::move(rng_in))
-         , m_session_mgr(*m_rng)
          , m_policy(std::move(policy))
-         {}
+         {
+         if(session.has_value())
+            {
+            m_session_mgr.save(Session(session->data(), session->size()));
+            }
+         }
 
    public:
       virtual ~TLS_Context() = default;
@@ -477,6 +575,11 @@ class TLS_Context
          m_callbacks.reset_callback_invocation_counters();
          }
 
+      std::vector<std::vector<uint8_t>> stored_sessions() const
+         {
+         return m_session_mgr.all_sessions();
+         }
+
       const std::vector<Botan::X509_Certificate>& certs_verified() const
          {
          return m_callbacks.certificate_chain;
@@ -489,7 +592,7 @@ class TLS_Context
       Test_Client_Credentials m_creds;
 
       std::unique_ptr<Botan::RandomNumberGenerator> m_rng;
-      Botan::TLS::Session_Manager_In_Memory         m_session_mgr;
+      RFC8448_Session_Manager                       m_session_mgr;
       RFC8448_Text_Policy                           m_policy;
    };
 
@@ -500,8 +603,9 @@ class Client_Context : public TLS_Context
                      RFC8448_Text_Policy policy,
                      uint64_t timestamp,
                      Modify_Exts_Fn modify_exts_cb,
+                     std::optional<std::vector<uint8_t>> session = std::nullopt,
                      MockSignature_Fn mock_signature_cb = [](auto,auto,auto) { return std::vector<uint8_t>(); })
-         : TLS_Context(std::move(rng_in), std::move(policy), std::move(modify_exts_cb), std::move(mock_signature_cb), timestamp)
+         : TLS_Context(std::move(rng_in), std::move(policy), std::move(modify_exts_cb), std::move(mock_signature_cb), timestamp, std::move(session))
          , client(m_callbacks, m_session_mgr, m_creds, m_policy, *m_rng,
                   Botan::TLS::Server_Information("server"),
                   Botan::TLS::Protocol_Version::TLS_V13)
@@ -530,6 +634,7 @@ void sort_extensions(Botan::TLS::Extensions& exts, Botan::TLS::Connection_Side s
          Botan::TLS::Handshake_Extension_Type::TLSEXT_SUPPORTED_GROUPS,
          Botan::TLS::Handshake_Extension_Type::TLSEXT_SESSION_TICKET,
          Botan::TLS::Handshake_Extension_Type::TLSEXT_KEY_SHARE,
+         Botan::TLS::Handshake_Extension_Type::TLSEXT_EARLY_DATA,
          Botan::TLS::Handshake_Extension_Type::TLSEXT_SUPPORTED_VERSIONS,
          Botan::TLS::Handshake_Extension_Type::TLSEXT_SIGNATURE_ALGORITHMS,
          Botan::TLS::Handshake_Extension_Type::TLSEXT_COOKIE,
@@ -549,15 +654,6 @@ void sort_extensions(Botan::TLS::Extensions& exts, Botan::TLS::Connection_Side s
       }
    }
 
-void add_psk_exchange_modes(Botan::TLS::Extensions& exts)
-   {
-   // Currently we do not support PSK and session resumption in TLS 1.3.
-   // Hence, we add this extension to please the test vector. The actual
-   // resumption is not exercised in this test, though. Once PSK is
-   // implemented, this should be removed and added in Client_Hello_13.
-   exts.add(new PSK_Key_Exchange_Modes({PSK_Key_Exchange_Mode::PSK_DHE_KE}));
-   }
-
 void add_renegotiation_extension(Botan::TLS::Extensions& exts)
    {
    // Renegotiation is not possible in TLS 1.3. Nevertheless, RFC 8448 requires
@@ -565,6 +661,11 @@ void add_renegotiation_extension(Botan::TLS::Extensions& exts)
    exts.add(new Renegotiation_Extension());
    }
 
+void add_early_data_indication(Botan::TLS::Extensions& exts)
+   {
+   exts.add(new Botan::TLS::EarlyDataIndication());
+   }
+
 }  // namespace
 
 /**
@@ -594,13 +695,15 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
    {
    public:
       Test_TLS_RFC8448()
-         : Text_Based_Test("tls_13_rfc8448/transcripts.vec", "RNG_Pool,CurrentTimestamp,ClientHello_1,ServerHello,ServerHandshakeMessages,ClientFinished,Client_CloseNotify,Server_CloseNotify", "HelloRetryRequest,ClientHello_2,NewSessionTicket,Client_AppData,Client_AppData_Record,Server_AppData,Server_AppData_Record,MessageToSign,MessageSignature") {}
+         : Text_Based_Test("tls_13_rfc8448/transcripts.vec", "RNG_Pool,CurrentTimestamp,ClientHello_1,ServerHello,ServerHandshakeMessages,ClientFinished,Client_CloseNotify,Server_CloseNotify", "HelloRetryRequest,ClientHello_2,NewSessionTicket,Client_AppData,Client_AppData_Record,Server_AppData,Server_AppData_Record,Client_EarlyAppData,Client_EarlyAppData_Record,SessionTicket,MessageToSign,MessageSignature") {}
 
    Test::Result run_one_test(const std::string& header,
                              const VarMap& vars) override
       {
       if(header == "Simple_1RTT_Handshake")
          return Test::Result("Simple 1-RTT (Client side)", simple_1_rtt_client_hello(vars));
+      else if(header == "Resumed_0RTT_Handshake")
+         return Test::Result("Resumption with 0-RTT data", resumed_handshake_with_0_rtt(vars));
       else if(header == "HelloRetryRequest_Handshake")
          return Test::Result("Handshake involving Hello Retry Request (Client side)", hello_retry_request(vars));
       else if(header == "Client_Authentication_Handshake")
@@ -615,7 +718,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
       static std::vector<Test::Result> simple_1_rtt_client_hello(const VarMap& vars)
          {
          auto rng = std::make_unique<Botan_Tests::Fixed_Output_RNG>("");
-         rng->add_entropy(std::vector<uint8_t>(32).data(), 32);  // used by session mgr for session key
 
          // 32 - for client hello random
          // 32 - for KeyShare (eph. x25519 key pair)
@@ -628,7 +730,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
             // this obsoleted extension in a TLS 1.3 client.
             exts.add(new Botan::TLS::Session_Ticket());
 
-            add_psk_exchange_modes(exts);
             add_renegotiation_extension(exts);
             sort_extensions(exts, side);
             };
@@ -648,6 +749,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Server Hello", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                const auto server_hello = vars.get_req_bin("ServerHello");
                // splitting the input data to test partial reads
                const std::vector<uint8_t> server_hello_a(server_hello.begin(), server_hello.begin() + 20);
@@ -664,6 +766,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Server HS messages .. Client Finished", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.received_data(vars.get_req_bin("ServerHandshakeMessages"));
 
                ctx->check_callback_invocations(result, "encrypted handshake messages received",
@@ -687,14 +790,24 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Post-Handshake: NewSessionTicket", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
+               result.require("no sessions so far", ctx->stored_sessions().empty());
                ctx->client.received_data(vars.get_req_bin("NewSessionTicket"));
 
-               // TODO: once we implement session resumption, this should probably expect some callback
-               ctx->check_callback_invocations(result, "new session ticket received", { });
+               ctx->check_callback_invocations(result, "new session ticket received",
+                  {
+                  "tls_session_ticket_received",
+                  "tls_current_timestamp"
+                  });
+               if(result.test_eq("session was stored", ctx->stored_sessions().size(), 1))
+                  {
+                  result.test_eq("session was serialized as expected", ctx->stored_sessions().front(), vars.get_req_bin("SessionTicket"));
+                  }
                }),
 
             Botan_Tests::CHECK("Send Application Data", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->send(vars.get_req_bin("Client_AppData"));
 
                ctx->check_callback_invocations(result, "application data sent", { "tls_emit_data" });
@@ -705,6 +818,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Receive Application Data", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.received_data(vars.get_req_bin("Server_AppData_Record"));
 
                ctx->check_callback_invocations(result, "application data sent", { "tls_record_received" });
@@ -716,6 +830,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Close Connection", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.close();
 
                result.test_eq("close payload", ctx->pull_send_buffer(), vars.get_req_bin("Client_CloseNotify"));
@@ -729,6 +844,51 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
             };
          }
 
+      static std::vector<Test::Result> resumed_handshake_with_0_rtt(const VarMap& vars)
+         {
+         auto rng = std::make_unique<Botan_Tests::Fixed_Output_RNG>("");
+
+         // 32 - for client hello random
+         // 32 - for KeyShare (eph. x25519 key pair)
+         add_entropy(*rng, vars.get_req_bin("RNG_Pool"));
+
+         auto add_extensions_and_sort = [](Botan::TLS::Extensions& exts, Botan::TLS::Connection_Side side)
+            {
+            exts.add(new Padding(87));
+
+            add_renegotiation_extension(exts);
+
+            // TODO: Implement early data support and remove this 'hack'.
+            //
+            // Currently, the production implementation will never add this
+            // extension even if the resumed session would allow early data.
+            add_early_data_indication(exts);
+            sort_extensions(exts, side);
+            };
+
+         std::unique_ptr<Client_Context> ctx;
+
+         return {
+            Botan_Tests::CHECK("Client Hello", [&](Test::Result& result)
+               {
+               ctx = std::make_unique<Client_Context>(std::move(rng),
+                                                      read_tls_policy("rfc8448_1rtt"),
+                                                      vars.get_req_u64("CurrentTimestamp"),
+                                                      add_extensions_and_sort,
+                                                      vars.get_req_bin("SessionTicket"));
+
+               result.confirm("client not closed", !ctx->client.is_closed());
+               ctx->check_callback_invocations(result, "client hello prepared", { "tls_emit_data", "tls_inspect_handshake_msg_client_hello", "tls_modify_extensions", "tls_current_timestamp" });
+
+               result.test_eq("TLS client hello", ctx->pull_send_buffer(), vars.get_req_bin("ClientHello_1"));
+               })
+
+            // TODO: The rest of this test vector requires 0-RTT which is not
+            //       yet implemented. For now we can only test the client's
+            //       ability to offer a session resumption via PSK.
+            };
+         }
+
       static std::vector<Test::Result> hello_retry_request(const VarMap& vars)
          {
          auto add_extensions_and_sort = [flights = 0](Botan::TLS::Extensions& exts, Botan::TLS::Connection_Side side) mutable
@@ -737,7 +897,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             if(flights == 1)
                {
-               add_psk_exchange_modes(exts);
                add_renegotiation_extension(exts);
                }
 
@@ -755,8 +914,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
          auto& fallback_rng = Test::rng();
          auto rng = std::make_unique<Botan_Tests::Fixed_Output_RNG>(fallback_rng);
 
-         rng->add_entropy(std::vector<uint8_t>(32).data(), 32);  // used by session mgr for session key
-
          // 32 - client hello random
          // 32 - eph. x25519 key pair
          // 32 - eph. P-256 key pair
@@ -777,6 +934,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Hello Retry Request .. second Client Hello", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.received_data(vars.get_req_bin("HelloRetryRequest"));
 
                ctx->check_callback_invocations(result, "hello retry request received",
@@ -794,6 +952,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Server Hello", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.received_data(vars.get_req_bin("ServerHello"));
 
                ctx->check_callback_invocations(result, "server hello received", { "tls_inspect_handshake_msg_server_hello", "tls_examine_extensions", "tls_decode_group_param" });
@@ -801,6 +960,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Server HS Messages .. Client Finished", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.received_data(vars.get_req_bin("ServerHandshakeMessages"));
 
                ctx->check_callback_invocations(result, "encrypted handshake messages received",
@@ -821,6 +981,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Close Connection", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.close();
                ctx->check_callback_invocations(result, "encrypted handshake messages received", { "tls_emit_data" });
                result.test_eq("client close notify", ctx->pull_send_buffer(), vars.get_req_bin("Client_CloseNotify"));
@@ -836,7 +997,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
       static std::vector<Test::Result> client_authentication(const VarMap& vars)
          {
          auto rng = std::make_unique<Botan_Tests::Fixed_Output_RNG>("");
-         rng->add_entropy(std::vector<uint8_t>(32).data(), 32);  // used by session mgr for session key
 
          // 32 - for client hello random
          // 32 - for eph. x25519 key pair
@@ -844,7 +1004,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
          auto add_extensions_and_sort = [&](Botan::TLS::Extensions& exts, Botan::TLS::Connection_Side side)
             {
-            add_psk_exchange_modes(exts);
             add_renegotiation_extension(exts);
             sort_extensions(exts, side);
             };
@@ -876,7 +1035,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
          return {
             Botan_Tests::CHECK("Client Hello", [&](Test::Result& result)
                {
-               ctx = std::make_unique<Client_Context>(std::move(rng), read_tls_policy("rfc8448_1rtt"), vars.get_req_u64("CurrentTimestamp"), add_extensions_and_sort, sign_certificate_verify);
+               ctx = std::make_unique<Client_Context>(std::move(rng), read_tls_policy("rfc8448_1rtt"), vars.get_req_u64("CurrentTimestamp"), add_extensions_and_sort, std::nullopt, sign_certificate_verify);
 
                ctx->check_callback_invocations(result, "initial callbacks", {
                   "tls_emit_data",
@@ -942,7 +1101,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
       static std::vector<Test::Result> middlebox_compatibility(const VarMap& vars)
          {
          auto rng = std::make_unique<Botan_Tests::Fixed_Output_RNG>("");
-         rng->add_entropy(std::vector<uint8_t>(32).data(), 32);  // used by session mgr for session key
 
          // 32 - client hello random
          // 32 - legacy session ID
@@ -952,7 +1110,6 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
          auto add_extensions_and_sort = [&](Botan::TLS::Extensions& exts, Botan::TLS::Connection_Side side)
             {
             add_renegotiation_extension(exts);
-            add_psk_exchange_modes(exts);
             sort_extensions(exts, side);
             };
 
@@ -968,6 +1125,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Server Hello + other handshake messages", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.received_data(Botan::concat(vars.get_req_bin("ServerHello"),
                                                       // ServerHandshakeMessages contains the expected ChangeCipherSpec record
                                                       vars.get_req_bin("ServerHandshakeMessages")));
@@ -981,6 +1139,7 @@ class Test_TLS_RFC8448 final : public Text_Based_Test
 
             Botan_Tests::CHECK("Close connection", [&](Test::Result& result)
                {
+               result.require("ctx is available", ctx != nullptr);
                ctx->client.close();
 
                result.test_eq("Client close_notify", ctx->pull_send_buffer(), vars.get_req_bin("Client_CloseNotify"));
diff --git a/src/tests/test_tls_transcript_hash_13.cpp b/src/tests/test_tls_transcript_hash_13.cpp
index 39bd0cf42..e142a0607 100644
--- a/src/tests/test_tls_transcript_hash_13.cpp
+++ b/src/tests/test_tls_transcript_hash_13.cpp
@@ -12,6 +12,7 @@
 #include <array>
 
 #include <botan/internal/tls_transcript_hash_13.h>
+#include <botan/hex.h>
 
 using namespace Botan::TLS;
 
@@ -25,6 +26,39 @@ std::vector<Test::Result> transcript_hash()
       return Botan::unlock(Botan::HashFunction::create_or_throw("SHA-256")->process(Botan::hex_decode(str)));
    };
 
+   // Client Hello taken from RFC 8448 0-RTT
+   const auto psk_client_hello = Botan::hex_decode(
+         "01 00 01 fc 03 03 1b c3 ce b6 bb e3 9c ff"
+         "93 83 55 b5 a5 0a db 6d b2 1b 7a 6a f6 49 d7 b4 bc 41 9d 78 76"
+         "48 7d 95 00 00 06 13 01 13 03 13 02 01 00 01 cd 00 00 00 0b 00"
+         "09 00 00 06 73 65 72 76 65 72 ff 01 00 01 00 00 0a 00 14 00 12"
+         "00 1d 00 17 00 18 00 19 01 00 01 01 01 02 01 03 01 04 00 33 00"
+         "26 00 24 00 1d 00 20 e4 ff b6 8a c0 5f 8d 96 c9 9d a2 66 98 34"
+         "6c 6b e1 64 82 ba dd da fe 05 1a 66 b4 f1 8d 66 8f 0b 00 2a 00"
+         "00 00 2b 00 03 02 03 04 00 0d 00 20 00 1e 04 03 05 03 06 03 02"
+         "03 08 04 08 05 08 06 04 01 05 01 06 01 02 01 04 02 05 02 06 02"
+         "02 02 00 2d 00 02 01 01 00 1c 00 02 40 01 00 15 00 57 00 00 00"
+         "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
+         "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
+         "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
+         "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"
+         "00 29 00 dd 00 b8 00 b2 2c 03 5d 82 93 59 ee 5f f7 af 4e c9 00"
+         "00 00 00 26 2a 64 94 dc 48 6d 2c 8a 34 cb 33 fa 90 bf 1b 00 70"
+         "ad 3c 49 88 83 c9 36 7c 09 a2 be 78 5a bc 55 cd 22 60 97 a3 a9"
+         "82 11 72 83 f8 2a 03 a1 43 ef d3 ff 5d d3 6d 64 e8 61 be 7f d6"
+         "1d 28 27 db 27 9c ce 14 50 77 d4 54 a3 66 4d 4e 6d a4 d2 9e e0"
+         "37 25 a6 a4 da fc d0 fc 67 d2 ae a7 05 29 51 3e 3d a2 67 7f a5"
+         "90 6c 5b 3f 7d 8f 92 f2 28 bd a4 0d da 72 14 70 f9 fb f2 97 b5"
+         "ae a6 17 64 6f ac 5c 03 27 2e 97 07 27 c6 21 a7 91 41 ef 5f 7d"
+         "e6 50 5e 5b fb c3 88 e9 33 43 69 40 93 93 4a e4 d3 57 fa d6 aa"
+         "cb 00 21 20 3a dd 4f b2 d8 fd f8 22 a0 ca 3c f7 67 8e f5 e8 8d"
+         "ae 99 01 41 c5 92 4d 57 bb 6f a3 1b 9e 5f 9d");
+
+   const auto sha256_truncated_ch = Botan::hex_decode(
+      "63224b2e4573f2d3454ca84b9d009a04f6be9e05711a8396473aefa01e924a14");
+   const auto sha256_full_ch = Botan::hex_decode(
+      "08ad0fa05d7c7233b1775ba2ff9f4c5b8b59276b7f227f13a976245f5d960913");
+
    return
       {
       Botan_Tests::CHECK("trying to get 'previous' or 'current' with invalid state", [](Test::Result& result)
@@ -144,6 +178,25 @@ std::vector<Test::Result> transcript_hash()
          const std::string transcript = "fe000020" + hash_of_client_hello + "c001f00d";
          result.test_eq("transcript hash of hello retry request", h2.current(), sha256(transcript));
          }),
+
+      Botan_Tests::CHECK("truncated transcript hash in client hellos with PSK", [&](Test::Result& result)
+         {
+         Transcript_Hash_State h1;
+
+         const auto truncation_mark = 477u;
+         auto truncated_ch = psk_client_hello;
+         truncated_ch.resize(truncation_mark);
+
+         h1.update(psk_client_hello);
+         h1.set_algorithm("SHA-256");
+
+         result.test_eq("truncated hash", h1.truncated(), sha256_truncated_ch);
+         result.test_eq("current hash", h1.current(), sha256_full_ch);
+
+         // truncated hash is cleared as soon as new messages are read
+         h1.update({0xc0, 0xca, 0xc0, 0x1a} /* server hello */);
+         result.test_throws("truncated hash is cleared", [&] { h1.truncated(); });
+         }),
       };
    }
 
diff --git a/src/tests/tests.h b/src/tests/tests.h
index 21634db17..98a7a07ce 100644
--- a/src/tests/tests.h
+++ b/src/tests/tests.h
@@ -282,7 +282,7 @@ class Test
                {
                if(!confirm(what, expr, expected))
                   {
-                  throw Test_Aborted("test aborted, because required condition was not met");
+                  throw Test_Aborted("test aborted, because required condition was not met: " + what);
                   }
                }