#5324·WasmEdge

bug: SecretVec silently copies when constructed from a std::vector rvalue

Author: parthdagia05Created Sep 1, 2026Updated Sep 1, 2026
Labelsbug

Summary

SecretVec in plugins/wasi_crypto/utils/secret_vec.h has no constructor taking std::vector<uint8_t>&&. When one is passed, overload resolution falls through to SecretVec(Span<const uint8_t>) via the span container conversion, which copies the bytes. The std::move at the call site has no effect, and the source vector is then destroyed by ordinary vector semantics without OPENSSL_cleanse, which is the one thing the class exists to guarantee.

Current State

ArrayOutput in plugins/wasi_crypto/common/array_output.h stores a const SecretVec and offers two constructors:

ArrayOutput(std::vector<uint8_t> &&Data) noexcept : Data(std::move(Data)) {}
ArrayOutput(SecretVec &&Data) noexcept : Data(std::move(Data)) {}

The second moves. The first copies, silently, because no matching SecretVec constructor exists. The payload is duplicated and the caller's buffer is freed uncleansed.

Nothing is leaking today. Everything currently reaching the std::vector<uint8_t>&& overload is public data: Signatures::sigExportData and the public key exportData of x25519, ML-KEM, EdDSA, ECDSA and RSA. Every secret path already returns SecretVec, namely skExportData, kpExportData, Symmetric::keyExportData, Kx::dh, and the ML-KEM shared secret, and each of those takes the moving overload. So the cost right now is one needless allocation and copy per export.

The reason I am filing it anyway is that the trap is invisible. A future export that returns a plain std::vector of secret bytes will compile, will appear to move into a cleansing container, and will leave the secret sitting in freed heap memory. Neither the type system nor any build warning flags it.

Expected State

Passing a std::vector<uint8_t> rvalue to SecretVec should transfer ownership of the buffer rather than copy it, so that exactly one copy of the bytes exists and that copy is cleansed on destruction. Passing an lvalue should keep copying through Span, as it does now.

One constructor achieves this:

SecretVec(std::vector<uint8_t> &&Data) noexcept : Data(std::move(Data)) {}

It is an exact match for a vector rvalue, so it beats the user defined conversion to Span with no ambiguity. Every existing call site keeps compiling, and the ones passing a vector rvalue start actually moving.

Reproduction steps

  1. Configure with the plugin and tests enabled: cmake -S . -B build -DWASMEDGE_PLUGIN_WASI_CRYPTO=ON -DWASMEDGE_BUILD_TESTS=ON

  2. Add this case to test/plugins/wasi_crypto/common.cpp, which is already part of the wasiCryptoTests target, so no CMake change is needed:

    #include "utils/secret_vec.h"
    
    TEST(SecretVec, MoveFromVector) {
      std::vector<uint8_t> Data(32, uint8_t{0xAB});
      const uint8_t *Before = Data.data();
      SecretVec Secret(std::move(Data));
      EXPECT_TRUE(Data.empty());
      EXPECT_EQ(Secret.data(), Before);
    }
    
  3. Build and run: cmake --build build --target wasiCryptoTests then cd build && ctest -R wasiCryptoTests

  4. Both expectations fail. The source vector still holds its 32 bytes and the destination points at a different allocation, showing the move was a copy.

Any logs you want to share for showing the specific issue

source size after move : 32 (expected 0) source buffer released : no destination reuses buf : no

Components

Plugins

WasmEdge Version or Commit you used

f65f6b1da

Operating system information

macOS 26.5

Hardware Architecture

arm64

Appendix

SecretVec's copy and move assignment operators are both defaulted, so assigning over a live SecretVec frees the destination's previous buffer without cleansing it. That is the same class of problem. I am happy to fold it into the same PR or leave it for a follow up, whichever you prefer.

Noticed while working on #5167, where @hydai suggested this should be a standalone PR. I would like to work on this issue.