diff --git a/CLAUDE.md b/CLAUDE.md index 08234d8..5ab9efb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ script/clang-tidy.sh - clang-tidy over src/ using build/compile_commands.js - Library target `sendspin_server_cpp` (alias `sendspin_server_cpp::sendspin_server_cpp`); CMake options are prefixed `SENDSPIN_SERVER_`. `SENDSPIN_SERVER_ENABLE_WERROR` (default off) makes warnings in this project's targets errors; CI builds with it on, so local builds should too. `SENDSPIN_SERVER_EMBEDDED_PROFILE` (default off) builds with `src/profile.h`'s embedded limits, as the ESP-IDF component (`cmake/esp_idf.cmake`) always does. - Plain: `cmake -B build && cmake --build build`, which builds `examples/` too (`SENDSPIN_SERVER_BUILD_EXAMPLES`, default on when top-level). Uses ccache when installed (`SENDSPIN_SERVER_USE_CCACHE`, default on when top-level). `SENDSPIN_SERVER_ENABLE_DISCOVERY` (default on) builds the mDNS backend: dns_sd on macOS, avahi-client through pkg-config on Linux (`libavahi-client-dev`). `SENDSPIN_SERVER_ENABLE_OPUS` (default on) builds the Opus encoder and the source role's Opus decoder; off compiles them out (`src/frame_encoder.cpp` and `src/source_decoder.cpp` are the places it is guarded) and is refused with the tests on. - Tests never use the network's mDNS: `tests/test_recorder.h` `local_config()` turns advertising and browsing off and listens on a port the system picks, and `test_server_discovery.cpp` supplies its own `DiscoveryProvider` through `ServerConfig::discovery`. -- Tests: `cmake -B build-asan -DSENDSPIN_SERVER_ENABLE_WERROR=ON -DSENDSPIN_SERVER_BUILD_TESTS=ON -DSENDSPIN_SERVER_ENABLE_SANITIZERS=ON`, then `cmake --build build-asan` and `ctest --test-dir build-asan --output-on-failure`. The suites whose assertions depend on players keeping up with real time carry the CTest label `realtime` (`tests/CMakeLists.txt`); CI runs them on their own and the rest in parallel (`-LE realtime -j`). The tests also build `server_cli`'s WAV and FLAC readers, feeders, `--one-group`, timing figures and log and `--web` dashboard; `tests/temp_dir.h` gives a test a temporary directory, and `tests/job_view.h` a copy of what a group's pipelines read of a host view. `tests/test_ports.h` hands out loopback ports for test peers, and holds one that nothing listens on for a dial that must be refused; `tests/raw_client.h` is the raw test client (noise-c and Beast, either suite, scriptable misbehavior), `tests/silent_listener.h` a listener that holds the server's dials in their upgrade or handshake, `tests/test_recorder.h` a recording listener, an in-memory provider and `LocalServer`, a server started with them, `tests/wire_messages.h` the spec's binary message types and fields as the tests decode them, `tests/flac_reader.h` decodes a FLAC stream as a player gets it, `tests/opus_reader.h` an Opus stream, with the test tones and an SNR to compare lossy audio by, `tests/player_sink.h` holds the test signal and a real-time audio output for sendspin-cpp players, `tests/peer.h` what each in-process sendspin-cpp client shares (its network provider and blob store, its config and the URL the server dials, its main loop run on the test thread, the client_id at a URL), and `tests/trust_fixtures.h` distinct PSKs and the client record a server saved. +- Tests: `cmake -B build-asan -DSENDSPIN_SERVER_ENABLE_WERROR=ON -DSENDSPIN_SERVER_BUILD_TESTS=ON -DSENDSPIN_SERVER_ENABLE_SANITIZERS=ON`, then `cmake --build build-asan` and `ctest --test-dir build-asan --output-on-failure`. The suites whose assertions depend on players keeping up with real time carry the CTest label `realtime` (`tests/CMakeLists.txt`); CI runs them on their own and the rest in parallel (`-LE realtime -j`). The tests also build `server_cli`'s WAV and FLAC readers, feeders, `--one-group`, timing figures and log and `--web` dashboard; `tests/temp_dir.h` gives a test a temporary directory, and `tests/job_view.h` a copy of what a group's pipelines read of a host view. `tests/test_ports.h` hands out loopback ports for test peers, and holds one that nothing listens on for a dial that must be refused; `tests/raw_client.h` is the raw test client (noise-c and Beast, either suite, scriptable misbehavior), `tests/silent_listener.h` a listener that holds the server's dials in their upgrade or handshake, `tests/test_recorder.h` a recording listener, an in-memory provider and `LocalServer`, a server started with them, `tests/wire_messages.h` the spec's binary message types and fields as the tests decode them, `tests/flac_reader.h` decodes a FLAC stream as a player gets it, `tests/opus_reader.h` an Opus stream, with the test tones and an SNR to compare lossy audio by, `tests/player_sink.h` holds the test signal and a real-time audio output for sendspin-cpp players, `tests/peer.h` what each in-process sendspin-cpp client shares (its network provider and blob store, its config and the URL the server dials, its main loop run on the test thread, the client_id at a URL), `tests/trust_fixtures.h` distinct PSKs and the client record a server saved, and `tests/freed_blocks.h` counts the heap blocks freed still holding a secret, through the sanitizer's free hook (a test that needs it skips itself without ASan or TSan). - Soak: `tests/soak.cpp` (its trace checker in `tests/soak_wire_checker.h`, tested in the default suite), target `sendspin_server_cpp_soak` with its own entry point, registered as the CTest test `Soak` (label `soak`), which skips itself unless `SENDSPIN_SERVER_SOAK_SECONDS` is set (`SENDSPIN_SERVER_SOAK_SEED` picks the script); README.md has the command. - CI also builds the library with Opus and discovery off and memory statistics on; as a subproject (`tests/consumer/`); and as the ESP-IDF component (`tests/esp_idf/`: `idf.py -C tests/esp_idf set-target esp32s3`, then `idf.py -C tests/esp_idf build`). - Memory statistics: `SENDSPIN_SERVER_MEMORY_STATS` (default off; `CONFIG_SENDSPIN_SERVER_MEMORY_STATS` in the component's `Kconfig`) logs the heaps and the tasks' stacks at the server's start (on the thread calling `start()`) and, on the core thread, at a connection's start and end and a group's playback (`profile::MEMORY_STATS`); the tests count log lines and expect it off. diff --git a/docs/conventions.md b/docs/conventions.md index f1b599b..ee0dbc0 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -46,7 +46,7 @@ The design standards that code in this repository is held to. This document is n ## Secrets - **Held in wiping types, wiped after use.** A private key or PSK lives in a type that wipes it when destroyed (`Identity`, `HandshakePsk`, `ClientRecord`, the ephemeral key a `NoiseInitiator` keeps), and a buffer that held one (a blob loaded or encoded for the provider) is wiped with `secure_zero()` once used. A secret is never formatted into a string. -- **Pairing secrets the same way.** A pairing code or token from the host, the CPace run and the values it unwraps are held in wiping types (`PairingAttempt`, `PairingPskToken`, the code's buffer on its way to core), and the buffers a pairing message was read from are wiped once it is parsed, whether or not it passed validation, spare capacity included, as is the reassembly buffer when a fragment sequence fails or the reassembler is destroyed. The message trace never sees one: `redact_pairing_secrets()` replaces each secret field a client sends before the plaintext is traced. +- **Pairing secrets the same way.** A pairing code or token from the host, the CPace run and the values it unwraps are held in wiping types (`PairingAttempt`, `PairingPskToken`, the code's buffer on its way to core), and the buffers a pairing message was read from are wiped once it is parsed, whether or not it passed validation, spare capacity included, as are the decrypted message's buffer and the reassembly buffer when a message fails to decrypt, a fragment sequence fails, the session ends or either is destroyed; either grows through `secure_reserve()`, which wipes the block it leaves. The message trace never sees one: `redact_pairing_secrets()` replaces each secret field a client sends before the plaintext is traced. - **Persisted before use.** A credential the server will reference in later handshakes (a long-term PSK) is referenced only once the provider reports it durable; until then the record as it was applies (`ServerCore::store_long_term_psk()`). - **Removed at once, written until durable.** A removed long-term PSK or approval (an unpairing, a revocation) stops applying at once, and a record write no caller waits on that the provider fails is written again until it lands or the server stops (`ServerCore::FailedWrites`), so a restart does not restore it once the provider recovers before the stop. diff --git a/src/crypto/crypto.h b/src/crypto/crypto.h index bc31803..72b94e0 100644 --- a/src/crypto/crypto.h +++ b/src/crypto/crypto.h @@ -55,6 +55,20 @@ inline void secure_zero_storage(std::vector& storage, size_t bytes) { storage.resize(size); } +/// @brief Reserves room for `capacity` bytes in a byte vector, as reserve() does, but wipes the +/// block it leaves first with secure_zero_storage(), as far as its size or `written`, whichever is +/// further; the block freed holds none of the contents +inline void secure_reserve(std::vector& storage, size_t capacity, size_t written) { + if (capacity <= storage.capacity()) { + return; + } + std::vector grown; + grown.reserve(capacity); + grown.assign(storage.begin(), storage.end()); + secure_zero_storage(storage, std::max(written, storage.size())); + storage.swap(grown); +} + constexpr size_t SHA256_DIGEST_SIZE = 32; /// @brief Streaming SHA-256 diff --git a/src/fragmentation.cpp b/src/fragmentation.cpp index aa7b59a..c08ff7c 100644 --- a/src/fragmentation.cpp +++ b/src/fragmentation.cpp @@ -94,6 +94,13 @@ Reassembler::Outcome Reassembler::accept(std::span plaintext) { if (this->buffer_.size() - 1 + data.size() > this->max_message_bytes_) { return this->fail(Outcome::TOO_LARGE); } + const size_t needed = this->buffer_.size() + data.size(); + if (needed > this->buffer_.capacity()) { + // At least doubled, as insert() would, within the cap + const size_t doubled = + std::min(2 * this->buffer_.capacity(), this->max_message_bytes_ + 1); + secure_reserve(this->buffer_, std::max(needed, doubled), this->written_); + } this->buffer_.insert(this->buffer_.end(), data.begin(), data.end()); this->written_ = std::max(this->written_, this->buffer_.size()); } diff --git a/src/session.cpp b/src/session.cpp index 9d1ef0b..e09396b 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -57,6 +57,10 @@ Session::Session(const Identity& identity, const SessionSettings& settings, Sess queues_(settings.queue_caps), credentials_(credentials) {} +Session::~Session() { + this->wipe_scratch(); +} + void Session::start(int64_t now_us) { this->deadline_us_ = now_us + this->settings_.step_timeout_us; } @@ -205,6 +209,9 @@ void Session::on_transport_message(MessageKind kind, std::span pa this->finish(SessionEnd::CLEARTEXT_IN_TRANSPORT); return; } + // Emptied first so a growth copies nothing it would overwrite + this->scratch_.clear(); + secure_reserve(this->scratch_, payload.size(), this->scratch_written_); this->scratch_.assign(payload.begin(), payload.end()); this->scratch_written_ = std::max(this->scratch_written_, this->scratch_.size()); if (!this->ciphers_ || !this->ciphers_->decrypt(this->scratch_)) { @@ -233,12 +240,9 @@ void Session::on_transport_message(MessageKind kind, std::span pa } return; case Reassembler::Outcome::MALFORMED: - // The reassembler wiped its buffer; the fragment here may carry part of a secret too - this->wipe_scratch(); this->finish(SessionEnd::MALFORMED_FRAGMENTS); return; case Reassembler::Outcome::TOO_LARGE: - this->wipe_scratch(); this->finish(SessionEnd::MESSAGE_TOO_LARGE); return; } @@ -1000,6 +1004,10 @@ void Session::finish(SessionEnd end) { } this->end_ = end; this->phase_ = SessionPhase::CLOSED; + // A pairing message's plaintext may be left: a fragment pending, or a message AES-GCM decrypted + // in place before its tag check failed + this->wipe_scratch(); + this->reassembler_.wipe(); this->initiator_.reset(); this->rekeys_.clear(); // A re-handshake that completed has no frozen activation left to write diff --git a/src/session.h b/src/session.h index 52c2c31..a9f6a2d 100644 --- a/src/session.h +++ b/src/session.h @@ -131,6 +131,12 @@ class Session { /// session Session(const Identity& identity, const SessionSettings& settings, SessionTrace* trace = nullptr, CredentialSource* credentials = nullptr); + /// @brief Wipes the decrypted message's buffer + ~Session(); + Session(const Session&) = delete; + Session& operator=(const Session&) = delete; + Session(Session&&) = delete; + Session& operator=(Session&&) = delete; /// @brief Starts the AWAIT_CLIENT_INIT timeout once the transport is connected. Called once, /// before anything else. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0d00295..74d1d92 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -161,8 +161,9 @@ set_source_files_properties(test_group_audio.cpp PROPERTIES COMPILE_OPTIONS -fno # White-box: test_session.cpp queues a control message behind the initial server/activate through # Session::queue_json(), since the server queues no other control message there. It reads # Session::scratch_, the buffer_ of Session::reassembler_ and of a Reassembler of its own to their -# capacity to show a pairing message's PSK, or a longer message's bytes, wiped from them, which -# nothing the session sends or reports shows. +# capacity to show a pairing message's PSK, or a longer message's bytes, wiped from them, and +# reads the capacity of the first two to show a longer message grew them, which nothing the session +# sends or reports shows. # test_server_core.cpp reads ServerCore::targets_ (a target's phase: ServerCore::RetryArmed, a # PendingDial) to show a dial waiting out its backoff, and a lost instance's target gone with its # wait, and a dial that waited not started once no longer due, diff --git a/tests/freed_blocks.h b/tests/freed_blocks.h new file mode 100644 index 0000000..e4037d8 --- /dev/null +++ b/tests/freed_blocks.h @@ -0,0 +1,125 @@ +// Copyright 2026 Sendspin Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file freed_blocks.h +/// @brief FreedBlockScan, which counts the heap blocks the test thread frees while it is alive that +/// still hold a secret, through the sanitizer's free hook; only in a build with ASan or TSan + +#pragma once + +#if defined(__SANITIZE_ADDRESS__) || defined(__SANITIZE_THREAD__) +#define SENDSPIN_TEST_FREE_HOOKS 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) || __has_feature(thread_sanitizer) +#define SENDSPIN_TEST_FREE_HOOKS 1 +#endif +#endif + +#include + +#include +#include +#include +#include + +#ifdef SENDSPIN_TEST_FREE_HOOKS +#if __has_include() +#include +#else +// GCC's runtimes export these but install no header declaring them +extern "C" { +int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void*, size_t), + void (*free_hook)(const volatile void*)); +size_t __sanitizer_get_allocated_size(const volatile void* pointer); +} +#endif +#endif + +namespace freed_blocks { + +/// The secret the live scan looks for; nullptr while none is alive +inline std::atomic needle{nullptr}; +/// The thread whose frees the live scan counts +inline std::atomic scanning_thread{}; +inline std::atomic holding{0}; + +#ifdef SENDSPIN_TEST_FREE_HOOKS +// Runs before the allocator takes the block back, so its bytes are still there. A container's +// spare capacity may be poisoned, so the block is read only in this uninstrumented function. +// Another thread's frees are not counted, and only the scanning thread ends the scan, so the +// secret it views outlives every read of it. +__attribute__((no_sanitize_address, no_sanitize_thread)) inline void on_free( + const volatile void* pointer) { + if (pointer == nullptr || pthread_equal(pthread_self(), scanning_thread.load()) == 0) { + return; + } + const std::string_view* secret = needle.load(); + if (secret == nullptr) { + return; + } + const void* block = const_cast(pointer); + const size_t size = __sanitizer_get_allocated_size(block); + const auto* data = static_cast(block); + for (size_t start = 0; start + secret->size() <= size; ++start) { + size_t matched = 0; + while (matched < secret->size() && + data[start + matched] == static_cast((*secret)[matched])) { + ++matched; + } + if (matched == secret->size()) { + holding.fetch_add(1); + return; + } + } +} + +inline void on_malloc(const volatile void* /*pointer*/, size_t /*size*/) {} +#endif + +} // namespace freed_blocks + +/// Counts the blocks the calling thread frees while it is alive that hold `secret`. One at a time. +class FreedBlockScan { +public: + /// @brief Whether the build has the hook; a test that needs it skips itself otherwise + static bool available() { +#ifdef SENDSPIN_TEST_FREE_HOOKS + // The sanitizer takes a few hook pairs and never removes one: installed once per process + static const bool installed = __sanitizer_install_malloc_and_free_hooks( + &freed_blocks::on_malloc, &freed_blocks::on_free) != 0; + return installed; +#else + return false; +#endif + } + + explicit FreedBlockScan(std::string_view secret) : secret_(secret) { + freed_blocks::holding.store(0); + freed_blocks::scanning_thread.store(pthread_self()); + freed_blocks::needle.store(&this->secret_); + } + ~FreedBlockScan() { + freed_blocks::needle.store(nullptr); + } + FreedBlockScan(const FreedBlockScan&) = delete; + FreedBlockScan& operator=(const FreedBlockScan&) = delete; + + /// @brief The blocks freed holding the secret so far + int holding() const { + return freed_blocks::holding.load(); + } + +private: + std::string_view secret_; +}; diff --git a/tests/test_crypto.cpp b/tests/test_crypto.cpp index 38a366a..9a85a62 100644 --- a/tests/test_crypto.cpp +++ b/tests/test_crypto.cpp @@ -14,15 +14,18 @@ /// @file test_crypto.cpp /// @brief Base64url, peer ids, the server identity and the Sentinel constants (spec connection.md -/// "Identities" and "Pre-Shared Key"). +/// "Identities" and "Pre-Shared Key"), and the wipe of a byte vector's storage as it grows. #include "crypto/base64url.h" +#include "crypto/crypto.h" #include "crypto/keys.h" +#include "freed_blocks.h" #include #include #include #include +#include #include #include @@ -34,6 +37,7 @@ using sendspin::server::Identity; using sendspin::server::PEER_ID_SIZE; using sendspin::server::public_key_from_peer_id; using sendspin::server::PublicKey; +using sendspin::server::secure_reserve; std::string hex(const uint8_t* data, size_t size) { std::string out; @@ -45,6 +49,8 @@ std::string hex(const uint8_t* data, size_t size) { return out; } +constexpr std::string_view SECRET = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + TEST(Base64Url, EncodesAndDecodesRfc4648Vectors) { struct Row { std::vector bytes; @@ -206,4 +212,43 @@ TEST(SentinelPsk, MatchesThePublishedConstants) { EXPECT_EQ(sendspin::server::SENTINEL_PSK_ID, "GFsV9tLaSQm9HcFWpKsgYQOr7wFTvNUtkmFwuVz3zoo"); } +// A vector grown by secure_reserve() frees its old block wiped as far as it was written, past its +// size too, or to its size, and keeps its contents; one grown by its own insert() frees the block +// holding them: the Control. Room it has already moves nothing. +TEST(SecureReserve, WipesTheBlockItLeaves) { + if (!FreedBlockScan::available()) { + GTEST_SKIP() << "needs the sanitizer's free hook"; + } + { + std::vector control(SECRET.begin(), SECRET.end()); + control.shrink_to_fit(); + const FreedBlockScan scan(SECRET); + control.insert(control.end(), SECRET.size(), 'x'); + EXPECT_EQ(scan.holding(), 1); + } + + std::vector bytes{'x'}; + bytes.reserve(1 + SECRET.size()); + bytes.insert(bytes.end(), SECRET.begin(), SECRET.end()); + bytes.resize(1); + const size_t capacity = bytes.capacity(); + { + const FreedBlockScan scan(SECRET); + secure_reserve(bytes, capacity + 1, 1 + SECRET.size()); + EXPECT_EQ(scan.holding(), 0); + } + EXPECT_EQ(bytes, std::vector{'x'}); + EXPECT_EQ(bytes.capacity(), capacity + 1); + + const uint8_t* data = bytes.data(); + secure_reserve(bytes, bytes.capacity(), bytes.size()); + EXPECT_EQ(bytes.data(), data); + + // Its contents are wiped though none was counted as written + std::vector unwritten(SECRET.begin(), SECRET.end()); + const FreedBlockScan scan(SECRET); + secure_reserve(unwritten, unwritten.capacity() + 1, 0); + EXPECT_EQ(scan.holding(), 0); +} + } // namespace diff --git a/tests/test_session.cpp b/tests/test_session.cpp index 83648fe..687c321 100644 --- a/tests/test_session.cpp +++ b/tests/test_session.cpp @@ -20,6 +20,7 @@ #include "clock.h" #include "crypto/keys.h" #include "fragmentation.h" +#include "freed_blocks.h" #include "messages.h" #include "raw_client.h" #include "session.h" @@ -1226,6 +1227,110 @@ TEST_P(SessionTest, WipesTheSecretOfAPairingMessageWhetherOrNotItPassesValidatio } } +// A client/pair-finalize whose tag fails leaves no PSK in the decrypted message's buffer: AES-GCM +// decrypts in place before its tag check (ChaChaPoly checks first), and the buffer is wiped +TEST_P(SessionTest, WipesAPairingMessageWhoseTagFails) { + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + Harness h(GetParam()); + ASSERT_NO_FATAL_FAILURE(h.activate()); + Bytes message = h.client.encrypt_json( + R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk + R"("}})"); + message.back() ^= 0x01; + h.send(message); + EXPECT_EQ(h.session.end(), SessionEnd::DECRYPT_FAILED); + EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); +} + +// A session that ends while a pairing message's first fragment is pending leaves its PSK in +// neither buffer, whether the host closes it or the next message fails to decrypt. The scan finds +// the PSK before the end: the Control. +TEST_P(SessionTest, WipesAPendingPairingFragmentAtTheEnd) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + struct Case { + const char* name; + std::function end_session; + SessionEnd end; + }; + const std::vector cases{ + {"closed by the host", [](Harness& h) { h.session.close_by_host(); }, + SessionEnd::CLOSED_BY_HOST}, + {"a message that fails to decrypt", [](Harness& h) { h.send(Bytes(32, 0)); }, + SessionEnd::DECRYPT_FAILED}, + }; + for (const Case& c : cases) { + SCOPED_TRACE(c.name); + Harness h(GetParam()); + ASSERT_NO_FATAL_FAILURE(h.activate()); + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head = + bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); + first.insert(first.end(), head.begin(), head.end()); + h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); + EXPECT_TRUE(holds_to_capacity(h.session.scratch_, psk)); + EXPECT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + c.end_session(h); + EXPECT_EQ(h.session.end(), c.end); + EXPECT_FALSE(holds_to_capacity(h.session.scratch_, psk)); + EXPECT_FALSE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + } +} + +// A pairing message's first fragment, its PSK in it, followed by a longer one, grows both the +// decrypted message's buffer and the reassembly buffer; neither frees a block holding the PSK. +// The scan's Control is SecureReserve.WipesTheBlockItLeaves. +TEST_P(SessionTest, GrowsItsBuffersWithoutFreeingAPairingSecret) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + if (!FreedBlockScan::available()) { + GTEST_SKIP() << "needs the sanitizer's free hook"; + } + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + Harness h(GetParam()); + ASSERT_NO_FATAL_FAILURE(h.activate()); + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head = + bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); + first.insert(first.end(), head.begin(), head.end()); + h.send(h.fragment(FRAGMENT_FLAG_FIRST, first)); + ASSERT_TRUE(holds_to_capacity(h.session.scratch_, psk)); + ASSERT_TRUE(holds_to_capacity(h.session.reassembler_.buffer_, psk)); + const size_t scratch_capacity = h.session.scratch_.capacity(); + const size_t buffer_capacity = h.session.reassembler_.buffer_.capacity(); + const Bytes longer = h.fragment(0, Bytes(4 * scratch_capacity, 'a')); + { + const FreedBlockScan scan(psk); + h.send(longer); + EXPECT_EQ(scan.holding(), 0); + } + EXPECT_FALSE(h.session.end()); + EXPECT_GT(h.session.scratch_.capacity(), scratch_capacity); + EXPECT_GT(h.session.reassembler_.buffer_.capacity(), buffer_capacity); +} + +// A session destroyed while a pairing message's first fragment is pending, with no end before, +// frees neither buffer holding the PSK. The scan's Control is SecureReserve.WipesTheBlockItLeaves. +TEST_P(SessionTest, WipesAPendingPairingFragmentWhenDestroyed) { + using sendspin::server::FRAGMENT_FLAG_FIRST; + if (!FreedBlockScan::available()) { + GTEST_SKIP() << "needs the sanitizer's free hook"; + } + const std::string psk = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWY"; + std::optional h; + h.emplace(GetParam()); + ASSERT_NO_FATAL_FAILURE(h->activate()); + Bytes first{sendspin::server::MESSAGE_ID_JSON}; + const Bytes head = + bytes_of(R"({"type":"client/pair-finalize","payload":{"long_term_psk":")" + psk); + first.insert(first.end(), head.begin(), head.end()); + h->send(h->fragment(FRAGMENT_FLAG_FIRST, first)); + ASSERT_TRUE(holds_to_capacity(h->session.scratch_, psk)); + ASSERT_TRUE(holds_to_capacity(h->session.reassembler_.buffer_, psk)); + ASSERT_FALSE(h->session.end()); + const FreedBlockScan scan(psk); + h.reset(); + EXPECT_EQ(scan.holding(), 0); +} + // A wipe reaches the reassembly buffer's storage past a shorter message, where a longer one // written since the last wipe left its bytes. Before the wipe the scan finds them: the Control. TEST(Reassembler, WipesTheStoragePastTheMessage) {