Repository: randombit/botan
Pull request #3994 (2024-04-09 19:50:39)

Refactor: concat() is constexpr and can deal with std::array<>
This reworks the `concat()` helper, giving it similar smartness as `load_le` and friends. Most notably, it is now `constexpr` and gains support for statically sized containers (`std::array<>` and `std::span<T, size>`). Also, the desired output container can now be defined without using the (now removed) `concat_as<>` crutch.

The output container to use is determined along the following rules:
1. explicitly specified by the caller: `concat<std::vector<uint8_t>>(...)` or `concat<std::array<uint8_t, 32>>(...)`,
2. if all input containers have static extent, the output container is a `std::array` with the appropriate size,
3. if the first input container is dynamically allocated, use this one (that's how the old `concat` handled it),
4. fail compilation and urge the caller to explicitly specify an output container.

Example usages:

```C++
constexpr std::array<uint8_t, 4> a1{1,2,3,4};
constexpr std::array<uint8_t, 2> a2{5,6};
std::vector<uint8_t> v1{7,8,9};

constexpr std::array<uint_8_t, 6> c1 = concat(a1,a2); // {1,2,3,4,4,5}
std::vector<uint8_t> c2 = concat(v1, a2);             // {7,8,9,5,6}
auto c3 = concat<std::array<uint8_t, 4>>(v1, a2);     // runtime exception: size mismatch (4 != 5)
auto c4 = concat<StrongVector>(v1, a1);               // {7,8,9,1,2,3,4}
// ...
```

Currently, this implementation cannot handle range elements of move-only types (as it uses `std::copy()` internally). Technically, this could probably be resolved, but I refrained from doing that as we mostly concatenate strings anyway. Nevertheless, its worth to keep that in mind and extend it when necessary.


Patch:
diff --git a/src/lib/pubkey/curve448/ed448/ed448_internal.cpp b/src/lib/pubkey/curve448/ed448/ed448_internal.cpp
index eb8bf6b90..7922b0ef9 100644
--- a/src/lib/pubkey/curve448/ed448/ed448_internal.cpp
+++ b/src/lib/pubkey/curve448/ed448/ed448_internal.cpp
@@ -27,10 +27,10 @@ std::vector<uint8_t> dom4(uint8_t x, std::span<const uint8_t> y) {
    //            is an octet string of at most 255 octets. "SigEd448"
    //            is in ASCII (8 octets).
    BOTAN_ARG_CHECK(y.size() < 256, "y is too long");
-   return concat_as<std::vector<uint8_t>>(std::array<uint8_t, 8>{'S', 'i', 'g', 'E', 'd', '4', '4', '8'},
-                                          store_le(x),
-                                          store_le(static_cast<uint8_t>(y.size())),
-                                          y);
+   return concat<std::vector<uint8_t>>(std::array<uint8_t, 8>{'S', 'i', 'g', 'E', 'd', '4', '4', '8'},
+                                       store_le(x),
+                                       store_le(static_cast<uint8_t>(y.size())),
+                                       y);
 }
 
 template <ranges::spanable_range... Ts>
diff --git a/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp b/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp
index d81b31e5b..ade189fbf 100644
--- a/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp
+++ b/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp
@@ -159,7 +159,7 @@ class Dilithium_PublicKeyInternal {
       Dilithium_PublicKeyInternal& operator=(const Dilithium_PublicKeyInternal& other) = delete;
       Dilithium_PublicKeyInternal& operator=(Dilithium_PublicKeyInternal&& other) = delete;
 
-      std::vector<uint8_t> raw_pk() const { return concat_as<std::vector<uint8_t>>(m_rho, m_t1.polyvec_pack_t1()); }
+      std::vector<uint8_t> raw_pk() const { return concat<std::vector<uint8_t>>(m_rho, m_t1.polyvec_pack_t1()); }
 
       const std::vector<uint8_t>& raw_pk_shake256() const {
          BOTAN_STATE_CHECK(m_raw_pk_shake256.size() == DilithiumModeConstants::SEEDBYTES);
@@ -222,7 +222,7 @@ class Dilithium_PrivateKeyInternal {
       }
 
       secure_vector<uint8_t> raw_sk() const {
-         return concat_as<secure_vector<uint8_t>>(
+         return concat<secure_vector<uint8_t>>(
             m_rho, m_key, m_tr, m_s1.polyvec_pack_eta(m_mode), m_s2.polyvec_pack_eta(m_mode), m_t0.polyvec_pack_t0());
       }
 
diff --git a/src/lib/pubkey/frodokem/frodokem_common/frodokem.cpp b/src/lib/pubkey/frodokem/frodokem_common/frodokem.cpp
index b46420b24..fe40ef7fb 100644
--- a/src/lib/pubkey/frodokem/frodokem_common/frodokem.cpp
+++ b/src/lib/pubkey/frodokem/frodokem_common/frodokem.cpp
@@ -48,9 +48,7 @@ class FrodoKEM_PublicKeyInternal {
 
       const FrodoPublicKeyHash& hash() const { return m_hash; }
 
-      std::vector<uint8_t> serialize() const {
-         return concat_as<std::vector<uint8_t>>(seed_a(), b().pack(m_constants));
-      }
+      std::vector<uint8_t> serialize() const { return concat<std::vector<uint8_t>>(seed_a(), b().pack(m_constants)); }
 
    private:
       FrodoKEMConstants m_constants;
@@ -277,7 +275,7 @@ size_t FrodoKEM_PublicKey::estimated_strength() const {
 }
 
 std::vector<uint8_t> FrodoKEM_PublicKey::public_key_bits() const {
-   return concat_as<std::vector<uint8_t>>(m_public->seed_a(), m_public->b().pack(m_public->constants()));
+   return concat<std::vector<uint8_t>>(m_public->seed_a(), m_public->b().pack(m_public->constants()));
 }
 
 bool FrodoKEM_PublicKey::check_key(RandomNumberGenerator&, bool) const {
@@ -361,11 +359,11 @@ secure_vector<uint8_t> FrodoKEM_PrivateKey::private_key_bits() const {
 }
 
 secure_vector<uint8_t> FrodoKEM_PrivateKey::raw_private_key_bits() const {
-   return concat_as<secure_vector<uint8_t>>(m_private->s(),
-                                            m_public->seed_a(),
-                                            m_public->b().pack(m_public->constants()),
-                                            m_private->s_trans().serialize(),
-                                            m_public->hash());
+   return concat<secure_vector<uint8_t>>(m_private->s(),
+                                         m_public->seed_a(),
+                                         m_public->b().pack(m_public->constants()),
+                                         m_private->s_trans().serialize(),
+                                         m_public->hash());
 }
 
 std::unique_ptr<PK_Ops::KEM_Decryption> FrodoKEM_PrivateKey::create_kem_decryption_op(RandomNumberGenerator& rng,
diff --git a/src/lib/pubkey/sphincsplus/sphincsplus_common/sphincsplus.cpp b/src/lib/pubkey/sphincsplus/sphincsplus_common/sphincsplus.cpp
index 92a54840a..4f04e2f6a 100644
--- a/src/lib/pubkey/sphincsplus/sphincsplus_common/sphincsplus.cpp
+++ b/src/lib/pubkey/sphincsplus/sphincsplus_common/sphincsplus.cpp
@@ -46,7 +46,7 @@ class SphincsPlus_PublicKeyInternal final {
          BOTAN_ASSERT_NOMSG(s.empty());
       }
 
-      std::vector<uint8_t> key_bits() const { return concat_as<std::vector<uint8_t>>(m_public_seed, m_sphincs_root); }
+      std::vector<uint8_t> key_bits() const { return concat<std::vector<uint8_t>>(m_public_seed, m_sphincs_root); }
 
       const SphincsPublicSeed& seed() const { return m_public_seed; }
 
@@ -81,7 +81,7 @@ class SphincsPlus_PrivateKeyInternal final {
 
       const SphincsSecretPRF& prf() const { return m_prf; }
 
-      secure_vector<uint8_t> key_bits() const { return concat_as<secure_vector<uint8_t>>(m_secret_seed, m_prf); }
+      secure_vector<uint8_t> key_bits() const { return concat<secure_vector<uint8_t>>(m_secret_seed, m_prf); }
 
    private:
       SphincsSecretSeed m_secret_seed;
diff --git a/src/lib/pubkey/sphincsplus/sphincsplus_sha2/sp_hash_sha2.h b/src/lib/pubkey/sphincsplus/sphincsplus_sha2/sp_hash_sha2.h
index 65fb3920e..bd143a77f 100644
--- a/src/lib/pubkey/sphincsplus/sphincsplus_sha2/sp_hash_sha2.h
+++ b/src/lib/pubkey/sphincsplus/sphincsplus_sha2/sp_hash_sha2.h
@@ -51,7 +51,7 @@ class Sphincs_Hash_Functions_Sha2 : public Sphincs_Hash_Functions {
          m_sha_x_full->update(message);
 
          auto r_pk_buffer = m_sha_x_full->final();
-         std::vector<uint8_t> mgf1_input = concat_as<std::vector<uint8_t>>(r, m_pub_seed, r_pk_buffer);
+         std::vector<uint8_t> mgf1_input = concat<std::vector<uint8_t>>(r, m_pub_seed, r_pk_buffer);
 
          std::vector<uint8_t> digest(m_sphincs_params.h_msg_digest_bytes());
          mgf1_mask(*m_sha_x_full, mgf1_input.data(), mgf1_input.size(), digest.data(), digest.size());
diff --git a/src/lib/pubkey/xmss/xmss_privatekey.cpp b/src/lib/pubkey/xmss/xmss_privatekey.cpp
index e0d04515a..6cd572e19 100644
--- a/src/lib/pubkey/xmss/xmss_privatekey.cpp
+++ b/src/lib/pubkey/xmss/xmss_privatekey.cpp
@@ -135,7 +135,7 @@ class XMSS_PrivateKey_Internal {
          std::vector<uint8_t> wots_derivation_method;
          wots_derivation_method.push_back(static_cast<uint8_t>(m_wots_derivation_method));
 
-         return concat_as<secure_vector<uint8_t>>(
+         return concat<secure_vector<uint8_t>>(
             raw_public_key, unused_index, m_prf, m_private_seed, wots_derivation_method);
       }
 
diff --git a/src/lib/pubkey/xmss/xmss_publickey.cpp b/src/lib/pubkey/xmss/xmss_publickey.cpp
index a1c29732a..7e7576b9b 100644
--- a/src/lib/pubkey/xmss/xmss_publickey.cpp
+++ b/src/lib/pubkey/xmss/xmss_publickey.cpp
@@ -120,15 +120,7 @@ std::unique_ptr<PK_Ops::Verification> XMSS_PublicKey::create_x509_verification_o
 }
 
 std::vector<uint8_t> XMSS_PublicKey::raw_public_key() const {
-   std::vector<uint8_t> result{static_cast<uint8_t>(m_xmss_params.oid() >> 24),
-                               static_cast<uint8_t>(m_xmss_params.oid() >> 16),
-                               static_cast<uint8_t>(m_xmss_params.oid() >> 8),
-                               static_cast<uint8_t>(m_xmss_params.oid())};
-
-   std::copy(m_root.begin(), m_root.end(), std::back_inserter(result));
-   std::copy(m_public_seed.begin(), m_public_seed.end(), std::back_inserter(result));
-
-   return result;
+   return concat<std::vector<uint8_t>>(store_be(static_cast<uint32_t>(m_xmss_params.oid())), m_root, m_public_seed);
 }
 
 std::vector<uint8_t> XMSS_PublicKey::public_key_bits() const {
diff --git a/src/lib/pubkey/xmss/xmss_wots.cpp b/src/lib/pubkey/xmss/xmss_wots.cpp
index 6387dcb33..ba78788e4 100644
--- a/src/lib/pubkey/xmss/xmss_wots.cpp
+++ b/src/lib/pubkey/xmss/xmss_wots.cpp
@@ -137,7 +137,7 @@ XMSS_WOTS_PrivateKey::XMSS_WOTS_PrivateKey(XMSS_WOTS_Parameters params,
    m_key_data.resize(m_params.len());
    for(size_t i = 0; i < m_params.len(); ++i) {
       adrs.set_chain_address(static_cast<uint32_t>(i));
-      const auto data = concat_as<std::vector<uint8_t>>(public_seed, adrs.bytes());
+      const auto data = concat<std::vector<uint8_t>>(public_seed, adrs.bytes());
       hash.prf_keygen(m_key_data[i], private_seed, data);
    }
 }
diff --git a/src/lib/utils/concepts.h b/src/lib/utils/concepts.h
index 78366041a..522cc0581 100644
--- a/src/lib/utils/concepts.h
+++ b/src/lib/utils/concepts.h
@@ -33,14 +33,26 @@ struct is_strong_type<Strong<Ts...>> : std::true_type {};
 template <typename... Ts>
 constexpr bool is_strong_type_v = is_strong_type<std::remove_const_t<Ts>...>::value;
 
-template <typename T0, typename... Ts>
+template <typename T0 = void, typename... Ts>
 struct all_same {
-      static constexpr bool value = (std::is_same_v<T0, Ts> && ...);
+      static constexpr bool value = (std::is_same_v<T0, Ts> && ... && true);
 };
 
 template <typename... Ts>
 static constexpr bool all_same_v = all_same<Ts...>::value;
 
+namespace detail {
+
+/**
+ * Helper type to indicate that a certain type should be automatically
+ * detected based on the context.
+ */
+struct AutoDetect {
+      constexpr AutoDetect() = delete;
+};
+
+}  // namespace detail
+
 namespace ranges {
 
 /**
@@ -170,6 +182,9 @@ concept resizable_container = container<T> && requires(T& c, typename T::size_ty
                                                  c.resize(s);
                                               };
 
+template <typename T>
+concept reservable_container = container<T> && requires(T& c, typename T::size_type s) { c.reserve(s); };
+
 template <typename T>
 concept resizable_byte_buffer =
    contiguous_container<T> && resizable_container<T> && std::same_as<typename T::value_type, uint8_t>;
diff --git a/src/lib/utils/loadstor.h b/src/lib/utils/loadstor.h
index d163b00ec..8340dd8ae 100644
--- a/src/lib/utils/loadstor.h
+++ b/src/lib/utils/loadstor.h
@@ -128,10 +128,6 @@ enum class Endianness : bool {
    Little,
 };
 
-struct AutoDetect {
-      constexpr AutoDetect() = delete;
-};
-
 /**
  * @warning This function may return false if the native endianness is unknown
  * @returns true iff the native endianness matches the given endianness
diff --git a/src/lib/utils/stl_util.h b/src/lib/utils/stl_util.h
index 2629350f2..cb7217658 100644
--- a/src/lib/utils/stl_util.h
+++ b/src/lib/utils/stl_util.h
@@ -2,6 +2,7 @@
 * STL Utility Functions
 * (C) 1999-2007 Jack Lloyd
 * (C) 2015 Simon Warta (Kullo GmbH)
+* (C) 2023-2024 René Meusel - Rohde & Schwarz Cybersecurity
 *
 * Botan is released under the Simplified BSD License (see license.txt)
 */
@@ -191,29 +192,100 @@ class BufferStuffer {
       std::span<uint8_t> m_buffer;
 };
 
+namespace detail {
+
 /**
- * Concatenate an arbitrary number of buffers.
- * @return the concatenation of \p buffers as the container type of the first buffer
+ * Helper function that performs range size-checks as required given the
+ * selected output and input range types. If all lengths are known at compile
+ * time, this check will be performed at compile time as well. It will then
+ * instantiate an output range and concatenate the input ranges' contents.
  */
-template <typename... Ts>
-decltype(auto) concat(Ts&&... buffers) {
-   static_assert(sizeof...(buffers) > 0, "concat requires at least one buffer");
-
-   using result_t = std::remove_cvref_t<std::tuple_element_t<0, std::tuple<Ts...>>>;
-   result_t result;
-   result.reserve((buffers.size() + ...));
-   (result.insert(result.end(), buffers.begin(), buffers.end()), ...);
+template <ranges::spanable_range OutR, ranges::spanable_range... Rs>
+constexpr OutR concatenate(Rs&&... ranges)
+   requires(concepts::reservable_container<OutR> || ranges::statically_spanable_range<OutR>)
+{
+   OutR result;
+
+   // Prepare and validate the output range and construct a lambda that does the
+   // actual filling of the result buffer.
+   // (if no input ranges are given, GCC claims that fill_fn is unused)
+   [[maybe_unused]] auto fill_fn = [&] {
+      if constexpr(concepts::reservable_container<OutR>) {
+         // dynamically allocate the correct result byte length
+         const size_t total_size = (ranges.size() + ... + 0);
+         result.reserve(total_size);
+
+         // fill the result buffer using a back-inserter
+         return [&result](auto&& range) {
+            std::copy(std::ranges::begin(range), std::ranges::end(range), std::back_inserter(unpack(result)));
+         };
+      } else {
+         if constexpr((ranges::statically_spanable_range<Rs> && ... && true)) {
+            // all input ranges have a static extent, so check the total size at compile time
+            // (work around an issue in MSVC that warns `total_size` is unused)
+            [[maybe_unused]] constexpr size_t total_size = (decltype(std::span{ranges})::extent + ... + 0);
+            static_assert(result.size() == total_size, "size of result buffer does not match the sum of input buffers");
+         } else {
+            // at least one input range has a dynamic extent, so check the total size at runtime
+            const size_t total_size = (ranges.size() + ... + 0);
+            BOTAN_ARG_CHECK(result.size() == total_size,
+                            "result buffer has static extent that does not match the sum of input buffers");
+         }
+
+         // fill the result buffer and hold the current output-iterator position
+         return [itr = std::ranges::begin(result)](auto&& range) mutable {
+            std::copy(std::ranges::begin(range), std::ranges::end(range), itr);
+            std::advance(itr, std::ranges::size(range));
+         };
+      }
+   }();
+
+   // perform the actual concatenation
+   (fill_fn(std::forward<Rs>(ranges)), ...);
+
    return result;
 }
 
+}  // namespace detail
+
 /**
- * Concatenate an arbitrary number of buffers and define the output buffer
- * type as a mandatory template parameter.
- * @return the concatenation of \p buffers as the user-defined container type
+ * Concatenate an arbitrary number of buffers. Performs range-checks as needed.
+ *
+ * The output type can be auto-detected based on the input ranges, or explicitly
+ * specified by the caller. If all input ranges have a static extent, the total
+ * size is calculated at compile time and a statically sized std::array<> is used.
+ * Otherwise this tries to use the type of the first input range as output type.
+ *
+ * Alternatively, the output container type can be specified explicitly.
  */
-template <typename ResultT, typename... Ts>
-ResultT concat_as(Ts&&... buffers) {
-   return concat(ResultT(), std::forward<Ts>(buffers)...);
+template <typename OutR = detail::AutoDetect, ranges::spanable_range... Rs>
+constexpr auto concat(Rs&&... ranges)
+   requires(all_same_v<std::ranges::range_value_t<Rs>...>)
+{
+   if constexpr(std::same_as<detail::AutoDetect, OutR>) {
+      // Try to auto-detect a reasonable output type given the input ranges
+      static_assert(sizeof...(Rs) > 0, "Cannot auto-detect the output type if not a single input range is provided.");
+      using candidate_result_t = std::remove_cvref_t<std::tuple_element_t<0, std::tuple<Rs...>>>;
+      using result_range_value_t = std::remove_cvref_t<std::ranges::range_value_t<candidate_result_t>>;
+
+      if constexpr((ranges::statically_spanable_range<Rs> && ...)) {
+         // If all input ranges have a static extent, we can calculate the total size at compile time
+         // and therefore can use a statically sized output container. This is constexpr.
+         constexpr size_t total_size = (decltype(std::span{ranges})::extent + ... + 0);
+         using out_array_t = std::array<result_range_value_t, total_size>;
+         return detail::concatenate<out_array_t>(std::forward<Rs>(ranges)...);
+      } else {
+         // If at least one input range has a dynamic extent, we must use a dynamically allocated output container.
+         // We assume that the user wants to use the first input range's container type as output type.
+         static_assert(
+            concepts::reservable_container<candidate_result_t>,
+            "First input range has static extent, but a dynamically allocated output range is required. Please explicitly specify a dynamically allocatable output type.");
+         return detail::concatenate<candidate_result_t>(std::forward<Rs>(ranges)...);
+      }
+   } else {
+      // The caller has explicitly specified the output type
+      return detail::concatenate<OutR>(std::forward<Rs>(ranges)...);
+   }
 }
 
 template <typename... Alts, typename... Ts>
diff --git a/src/lib/utils/strong_type.h b/src/lib/utils/strong_type.h
index 03b380e76..031693ef6 100644
--- a/src/lib/utils/strong_type.h
+++ b/src/lib/utils/strong_type.h
@@ -138,6 +138,12 @@ class Strong_Adapter<T> : public Strong_Base<T> {
          this->get().resize(size);
       }
 
+      void reserve(size_type size) noexcept(noexcept(this->get().reserve(size)))
+         requires(concepts::reservable_container<T>)
+      {
+         this->get().reserve(size);
+      }
+
       decltype(auto) operator[](size_type i) const noexcept(noexcept(this->get().operator[](i))) {
          return this->get()[i];
       }
@@ -167,6 +173,19 @@ class Strong : public detail::Strong_Adapter<T> {
       using Tag = TagTypeT;
 };
 
+/**
+ * Opportunistically unpacks a strong type to its underlying type. If the
+ * provided type is not a strong type, it is returned as is.
+ */
+template <typename T>
+constexpr decltype(auto) unpack(T& t) {
+   if constexpr(concepts::strong_type<std::remove_cvref_t<T>>) {
+      return t.get();
+   } else {
+      return t;
+   }
+}
+
 template <typename T, typename... Tags>
    requires(concepts::streamable<T>) decltype(auto)
 operator<<(std::ostream& os, const Strong<T, Tags...>& v) {


Test patch:
diff --git a/src/tests/test_utils_buffer.cpp b/src/tests/test_utils_buffer.cpp
index 7d7c7a888..df7c4fbea 100644
--- a/src/tests/test_utils_buffer.cpp
+++ b/src/tests/test_utils_buffer.cpp
@@ -1,12 +1,13 @@
 /*
  * (C) 2023 Jack Lloyd
- * (C) 2023 René Meusel, Rohde & Schwarz Cybersecurity
+ * (C) 2023-2024 René Meusel, Rohde & Schwarz Cybersecurity
  *
  * Botan is released under the Simplified BSD License (see license.txt)
  */
 
 #include "tests.h"
 
+#include <botan/concepts.h>
 #include <botan/mem_ops.h>
 #include <botan/internal/alignment_buffer.h>
 #include <botan/internal/stl_util.h>
@@ -102,7 +103,7 @@ std::vector<Test::Result> test_buffer_slicer() {
                StrongBuffer vec4(s.remaining());
                s.copy_into(vec4);
 
-               const auto reproduce = Botan::concat_as<std::vector<uint8_t>>(span1, span2, vec1, vec2, vec3, vec4);
+               const auto reproduce = Botan::concat<std::vector<uint8_t>>(span1, span2, vec1, vec2, vec3, vec4);
                result.test_eq("sliced into various types", reproduce, secure_buffer);
             }),
    };
@@ -435,7 +436,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.test_eq("not enough data for alignment processing", r1, 0);
                result.test_eq("(short) unaligned data is not consumed", half_block.remaining(), 16);
 
-               const auto more_than_one_block = Botan::concat_as<std::vector<uint8_t>>(data, first_half_data);
+               const auto more_than_one_block = Botan::concat<std::vector<uint8_t>>(data, first_half_data);
                Botan::BufferSlicer one_and_a_half_block(more_than_one_block);
                const auto [s2, r2] = b.aligned_data_to_process(one_and_a_half_block);
                result.test_eq("data of one block for processing", s2.size(), 32);
@@ -443,7 +444,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.test_is_eq(v(s2), v(data));
                result.test_eq("(middle) unaligned data is not consumed", one_and_a_half_block.remaining(), 16);
 
-               const auto two_blocks_data = Botan::concat_as<std::vector<uint8_t>>(data, data);
+               const auto two_blocks_data = Botan::concat<std::vector<uint8_t>>(data, data);
                Botan::BufferSlicer two_blocks(two_blocks_data);
                const auto [s3, r3] = b.aligned_data_to_process(two_blocks);
                result.test_eq("data of two block for processing", s3.size(), 64);
@@ -462,7 +463,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.confirm("not enough data for alignment processing", !s1.has_value());
                result.test_eq("(short) unaligned data is not consumed", half_block.remaining(), 16);
 
-               const auto more_than_one_block = Botan::concat_as<std::vector<uint8_t>>(data, first_half_data);
+               const auto more_than_one_block = Botan::concat<std::vector<uint8_t>>(data, first_half_data);
                Botan::BufferSlicer one_and_a_half_block(more_than_one_block);
                const auto s2 = b.next_aligned_block_to_process(one_and_a_half_block);
                result.require("one block for processing", s2.has_value());
@@ -470,7 +471,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.test_is_eq(v(s2.value()), v(data));
                result.test_eq("(middle) unaligned data is not consumed", one_and_a_half_block.remaining(), 16);
 
-               const auto two_blocks_data = Botan::concat_as<std::vector<uint8_t>>(data, data);
+               const auto two_blocks_data = Botan::concat<std::vector<uint8_t>>(data, data);
                Botan::BufferSlicer two_blocks(two_blocks_data);
                const auto s3_1 = b.next_aligned_block_to_process(two_blocks);
                result.require("first block for processing", s3_1.has_value());
@@ -496,7 +497,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.test_eq("not enough data for alignment processing", r1, 0);
                result.test_eq("(short) unaligned data is not consumed", half_block.remaining(), 16);
 
-               const auto more_than_one_block = Botan::concat_as<std::vector<uint8_t>>(data, first_half_data);
+               const auto more_than_one_block = Botan::concat<std::vector<uint8_t>>(data, first_half_data);
                Botan::BufferSlicer one_and_a_half_block(more_than_one_block);
                const auto [s2, r2] = b.aligned_data_to_process(one_and_a_half_block);
                result.test_eq("data of one block for processing", s2.size(), 32);
@@ -504,7 +505,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.test_is_eq(v(s2), v(data));
                result.test_eq("(middle) unaligned data is not consumed", one_and_a_half_block.remaining(), 16);
 
-               const auto two_blocks_data = Botan::concat_as<std::vector<uint8_t>>(data, data);
+               const auto two_blocks_data = Botan::concat<std::vector<uint8_t>>(data, data);
                Botan::BufferSlicer two_blocks(two_blocks_data);
                const auto [s3, r3] = b.aligned_data_to_process(two_blocks);
                result.test_eq("data of first block for processing", s3.size(), 32);
@@ -523,7 +524,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.confirm("not enough data for alignment processing", !s1.has_value());
                result.test_eq("(short) unaligned data is not consumed", half_block.remaining(), 16);
 
-               const auto more_than_one_block = Botan::concat_as<std::vector<uint8_t>>(data, first_half_data);
+               const auto more_than_one_block = Botan::concat<std::vector<uint8_t>>(data, first_half_data);
                Botan::BufferSlicer one_and_a_half_block(more_than_one_block);
                const auto s2 = b.next_aligned_block_to_process(one_and_a_half_block);
                result.require("one block for processing", s2.has_value());
@@ -531,7 +532,7 @@ std::vector<Test::Result> test_alignment_buffer() {
                result.test_is_eq(v(s2.value()), v(data));
                result.test_eq("(middle) unaligned data is not consumed", one_and_a_half_block.remaining(), 16);
 
-               const auto two_blocks_data = Botan::concat_as<std::vector<uint8_t>>(data, data);
+               const auto two_blocks_data = Botan::concat<std::vector<uint8_t>>(data, data);
                Botan::BufferSlicer two_blocks(two_blocks_data);
                const auto s3_1 = b.next_aligned_block_to_process(two_blocks);
                result.require("first block for processing", s3_1.has_value());
@@ -546,7 +547,125 @@ std::vector<Test::Result> test_alignment_buffer() {
    };
 }
 
-BOTAN_REGISTER_TEST_FN("utils", "buffer_utilities", test_buffer_slicer, test_buffer_stuffer, test_alignment_buffer);
+std::vector<Test::Result> test_concat() {
+   return {
+      Botan_Tests::CHECK("empty concat",
+                         [](Test::Result& result) {
+                            // only define a dynamic output type, but no input to be concat'ed
+                            const auto empty1 = Botan::concat<std::vector<uint8_t>>();
+                            result.confirm("empty concat 1", empty1.empty());
+
+                            // pass an empty input buffer to be concat'ed
+                            const auto empty2 = Botan::concat(std::vector<uint8_t>());
+                            result.confirm("empty concat 2", empty2.empty());
+
+                            // pass multiple empty input buffers to be concat'ed
+                            const auto empty3 = Botan::concat(std::vector<uint8_t>(), Botan::secure_vector<uint8_t>());
+                            result.confirm("empty concat 3", empty3.empty());
+
+                            // pass multiple empty input buffers to be concat'ed without auto-detection of the output buffer
+                            const auto empty4 = Botan::concat<std::array<uint8_t, 0>>(
+                               std::vector<uint8_t>(), std::array<uint8_t, 0>(), Botan::secure_vector<uint8_t>());
+                            result.confirm("empty concat 4", empty4.empty());
+                         }),
+
+      Botan_Tests::CHECK(
+         "auto-detected output type",
+         [](Test::Result& result) {
+            // define a static output type without any input parameters
+            const auto t0 = Botan::concat<std::array<uint8_t, 0>>();
+            result.confirm("type 0", std::is_same_v<std::array<uint8_t, 0>, std::remove_cvref_t<decltype(t0)>>);
+
+            // define a dynamic output type without any input parameters
+            const auto t1 = Botan::concat<std::vector<uint8_t>>();
+            result.confirm("type 1", std::is_same_v<std::vector<uint8_t>, std::remove_cvref_t<decltype(t1)>>);
+
+            // pass a single dynamic input buffer and auto-detect result type
+            const auto t2 = Botan::concat(std::vector<uint8_t>());
+            result.confirm("type 2", std::is_same_v<std::vector<uint8_t>, std::remove_cvref_t<decltype(t2)>>);
+
+            // pass multiple dynamic input buffers and auto-detect result type
+            const auto t3 = Botan::concat(Botan::secure_vector<uint8_t>(), std::vector<uint8_t>());
+            result.confirm("type 3", std::is_same_v<Botan::secure_vector<uint8_t>, std::remove_cvref_t<decltype(t3)>>);
+
+            // pass a mixture of dynamic and static input buffers and auto-detect result type
+            const auto t4 =
+               Botan::concat(std::vector<uint8_t>(), std::array<uint8_t, 0>(), Botan::secure_vector<uint8_t>());
+            result.confirm("type 4", std::is_same_v<std::vector<uint8_t>, std::remove_cvref_t<decltype(t4)>>);
+
+            // pass only static input buffers and auto-detect result type
+            const std::array<uint8_t, 5> some_buffer{};
+            const auto t5 = Botan::concat(std::array<uint8_t, 1>(), std::array<uint8_t, 3>(), std::span{some_buffer});
+            result.confirm("type 5", std::is_same_v<std::array<uint8_t, 9>, std::remove_cvref_t<decltype(t5)>>);
+         }),
+
+      Botan_Tests::CHECK(
+         "concatenate",
+         [](Test::Result& result) {
+            constexpr std::array<uint8_t, 5> a1 = {1, 2, 3, 4, 5};
+            const std::vector<uint8_t> v1{6, 7, 8, 9, 10};
+
+            // concatenate a single buffer
+            const auto concat0 = Botan::concat<Botan::secure_vector<uint8_t>>(v1);
+            result.test_is_eq("concat 0", concat0, {6, 7, 8, 9, 10});
+
+            // concatenate into an dynamically allocated output buffer
+            const auto concat1 = Botan::concat<std::vector<uint8_t>>(a1, v1);
+            result.test_is_eq("concat 1", concat1, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
+
+            // concatenate into a statically sized output buffer
+            const auto concat2 = Botan::concat<std::array<uint8_t, 10>>(v1, a1);
+            result.test_is_eq("concat 2", concat2, {6, 7, 8, 9, 10, 1, 2, 3, 4, 5});
+
+            // concatenate into a statically sized output buffer, that is auto-detected
+            const auto concat3 = Botan::concat(a1, std::span<const uint8_t, 5>(v1));
+            result.test_is_eq("concat 3", concat3, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10});
+            result.confirm("correct type 3",
+                           std::is_same_v<std::array<uint8_t, 10>, std::remove_cvref_t<decltype(concat3)>>);
+
+            // concatenate into a statically sized output buffer, that is auto-detected, at compile time
+            constexpr auto concat4 = Botan::concat(a1, a1);
+            result.test_is_eq("concat 4", concat4, {1, 2, 3, 4, 5, 1, 2, 3, 4, 5});
+            result.confirm("correct type 4",
+                           std::is_same_v<std::array<uint8_t, 10>, std::remove_cvref_t<decltype(concat4)>>);
+         }),
+
+      Botan_Tests::CHECK("dynamic length-check",
+                         [](Test::Result& result) {
+                            std::vector<uint8_t> v1{1, 2, 3, 4, 5};
+                            std::vector<uint8_t> v2{6, 7, 8, 9, 10};
+
+                            result.test_throws("concatenate into a statically sized type with insufficient space",
+                                               [&]() { Botan::concat<std::array<uint8_t, 4>>(v1, v2); });
+                            result.test_throws("concatenate into a statically sized type with too much space",
+                                               [&]() { Botan::concat<std::array<uint8_t, 20>>(v1, v2); });
+                         }),
+
+      Botan_Tests::CHECK("concatenate strong types",
+                         [](Test::Result& result) {
+                            using StrongV = Botan::Strong<std::vector<uint8_t>, struct StrongV_>;
+                            using StrongA = Botan::Strong<std::array<uint8_t, 4>, struct StrongA_>;
+
+                            StrongV v1(std::vector<uint8_t>{1, 2, 3, 4, 5});
+                            StrongA a2;
+                            a2[0] = 6;
+                            a2[1] = 7;
+                            a2[2] = 8;
+                            a2[3] = 9;
+
+                            // concat strong types into a verbatim type
+                            auto concat1 = Botan::concat<std::vector<uint8_t>>(v1, a2);
+                            result.test_is_eq("concat 1", concat1, {1, 2, 3, 4, 5, 6, 7, 8, 9});
+
+                            // concat strong types into a dynamically allocated strong type
+                            auto concat2 = Botan::concat<StrongV>(a2, v1);
+                            result.test_is_eq("concat 2", concat2.get(), {6, 7, 8, 9, 1, 2, 3, 4, 5});
+                         }),
+   };
+}
+
+BOTAN_REGISTER_TEST_FN(
+   "utils", "buffer_utilities", test_buffer_slicer, test_buffer_stuffer, test_alignment_buffer, test_concat);
 
 }  // namespace