bug: SecretVec silently copies when constructed from a std::vector rvalue
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
Configure with the plugin and tests enabled:
cmake -S . -B build -DWASMEDGE_PLUGIN_WASI_CRYPTO=ON -DWASMEDGE_BUILD_TESTS=ONAdd this case to
test/plugins/wasi_crypto/common.cpp, which is already part of thewasiCryptoTeststarget, 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); }Build and run:
cmake --build build --target wasiCryptoTeststhencd build && ctest -R wasiCryptoTestsBoth 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.
Source: WasmEdge/WasmEdge