Repository navigation
Wipe the session's plaintext buffers when they grow and when it ends - #89
Merged
Merged
Conversation
A pairing message's plaintext could still reach the heap unwiped: the session's message buffer and the reassembly buffer freed their old block when they grew, the session ended without wiping its message buffer with a pairing fragment pending, and AES-GCM decrypts in place before its tag check, so a tampered client/pair-finalize left its PSK behind. secure_reserve() grows a byte vector by hand, wiping the block it leaves; both buffers grow through it. Session::finish() wipes both buffers, which covers a decrypt failure and the fragment failures, and the session's destructor wipes its message buffer.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The test-only allocator parameters violate the repository’s explicit prohibition on production test seams.
1 open finding
What changed in this PR
Protects pairing plaintext in session buffers during growth and cleanup, addressing #77.
Changes:
- Adds secure buffer growth that wipes replaced storage.
- Wipes plaintext buffers on session termination and destruction.
- Adds regression tests and updates secret-handling documentation.
| File | Description |
|---|---|
| tests/test_session.cpp | Tests cleanup after authentication failure and pending fragments. |
| tests/test_crypto.cpp | Tests wiping storage during buffer growth. |
| src/session.h | Declares destructor and disables copying and moving. |
| src/session.cpp | Adds secure growth and lifecycle wipes. |
| src/fragmentation.cpp | Wipes replaced reassembly storage. |
| src/crypto/crypto.h | Adds allocator-aware secure reservation. |
| docs/conventions.md | Documents expanded plaintext-wiping requirements. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The byte-vector wipe helpers took an allocator template parameter only so a test's scanning allocator could see a freed block, a seam the conventions forbid. They take std::vector<uint8_t> again, and tests/freed_blocks.h counts the blocks the test thread frees holding a secret through the sanitizer's free hook instead. That also reaches the session's two growth paths and its destruction, which the allocator could not.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
A pairing message's plaintext could still reach the heap unwiped. The session's message buffer (
scratch_) and the reassembly buffer freed their old block unwiped when they grew. A session that ended with a pairing fragment pending kept it inscratch_. On the host's AES-GCM backend, which decrypts in place before the tag check, a client/pair-finalize whose tag failed left itslong_term_pskinscratch_.Change
secure_reserve()(incrypto/crypto.h, next tosecure_zero_storage()) reserves the capacity asked for by hand and wipes the block it leaves, as far as its size or the bytes written, whichever is further.Reassembler::accept()calls it only when a fragment outgrows the buffer, at least doubling it within the cap asinsert()did. The session clearsscratch_before reserving, so a growth copies nothing.Session::finish()wipesscratch_and the reassembly buffer, which covers every end: a decrypt failure, a failed fragment sequence (so the session's separate wipes there are gone), a host close.~Session()wipesscratch_, for a connection dropped without an end; the reassembler already wipes in its destructor.docs/conventions.md"Pairing secrets the same way" describes the wipes.Tests
SessionTest.WipesAPairingMessageWhoseTagFails: a pair-finalize with one tag bit flipped ends DECRYPT_FAILED with no PSK inscratch_. It can fail only under AES-GCM, since ChaChaPoly checks the tag before it decrypts.SessionTest.WipesAPendingPairingFragmentAtTheEnd: with a pair-finalize's first fragment pending, a host close and a message that fails to decrypt each leave the PSK in neither buffer. Control: the scan finds it before the end.tests/freed_blocks.h:FreedBlockScancounts the blocks the test thread frees still holding a secret, through the sanitizer's free hook. A test that uses it skips itself in a build without ASan or TSan.SecureReserve.WipesTheBlockItLeaves: the scan shows thatsecure_reserve()frees its old block wiped (past the size as far as written, and to the size when nothing was counted as written), keeps the contents, reserves exactly what it is asked for, and moves nothing when the room is already there. Control: a vector grown by its owninsert()frees the block with the secret still in it.SessionTest.GrowsItsBuffersWithoutFreeingAPairingSecret: a pairing fragment followed by a longer one grows both buffers, and neither frees a block holding the PSK.SessionTest.WipesAPendingPairingFragmentWhenDestroyed: a session destroyed with a pairing fragment pending and no end frees neither buffer holding the PSK.tests/freed_blocks.hdeclares the two functions itself there. That was compiled locally but not run; CI's Linux ASan and TSan jobs are its first run.Closes #77