Repository navigation
fix(aead-frame)!: wipe the copies the zeroize audit found, and record the ones it accepts - #532
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
b229baf to
8a06198
Compare
💡 Codex Reviewvitaminc/bindings/go/vcffi/storage.go Lines 181 to 183 in b229baf For a vitaminc/packages/aead-frame/src/serde.rs Lines 846 to 848 in b229baf For storage maps with text keys, this routes deserialization through ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
✅ No CRAP threshold violations1500 function(s) analyzed · threshold 30 |
🧬 Mutation testing (cargo-mutants,
|
| caught | missed | unviable | timeout |
|---|---|---|---|
| 23 | 0 | 11 | 0 |
✅ Every mutant in the changed lines was caught by a test.
…the storage key writer writes them The shared reader read every text key through a deserializer that reported itself human readable, while the storage profile's key writer is not. A key type that writes a different text in each mode could not read its own stored keys back. The reader now knows the profile it reads for: transport keys stay human readable, as the transport's key writer is, and storage keys are compact. Found by Codex review on #532. Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
… every level of a nested pointer reads back A pointer was marked only when its target was written as null. A non-nil pointer to a pointer to nil wrote one marker, then null, and read back one level shorter: the reader took the marker for the outer pointer and the null for the middle one. The rule is now whether the target's encoding starts with null or the marker: a nil pointer or interface, Some, or a pointer to one. Also moves marshalText's doc comment back above it. Found by Codex review on #532. Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
87b5fd9 to
a2627c6
Compare
8a06198 to
4994a1f
Compare
|
Both findings from the Codex review are fixed. Neither is in code this PR changes, so each fix is in the PR that owns the code, and the stack is restacked on top. Preserve presence at every nested optional pointer level (
Preserve binary mode when reading storage text keys (
Rebasing #532 needed one change: |
… every level of a nested pointer reads back A pointer was marked only when its target was written as null. A non-nil pointer to a pointer to nil wrote one marker, then null, and read back one level shorter: the reader took the marker for the outer pointer and the null for the middle one. The rule is now whether the target's encoding starts with null or the marker: a nil pointer or interface, Some, or a pointer to one. Also moves marshalText's doc comment back above it. Found by Codex review on #532. Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
a2627c6 to
09a2ab3
Compare
4994a1f to
9c2cbe0
Compare
… every level of a nested pointer reads back A pointer was marked only when its target was written as null. A non-nil pointer to a pointer to nil wrote one marker, then null, and read back one level shorter: the reader took the marker for the outer pointer and the null for the middle one. The rule is now whether the target's encoding starts with null or the marker: a nil pointer or interface, Some, or a pointer to one. Also moves marshalText's doc comment back above it. Found by Codex review on #532. Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
09a2ab3 to
d57c5db
Compare
9c2cbe0 to
039186e
Compare
039186e to
fb57376
Compare
f1b2abc to
95a8a71
Compare
cipherstash-bot
left a comment
There was a problem hiding this comment.
Recommendation: 🟢 merge as it is
Nothing must change before merging. The comments below are test gaps and one misplaced doc line, and they can wait for a follow-up. The new wipe test covers the heap side well. The moved repeated-key refusal and the collect_str overrides have no test of their own in the transport profile.
How this review was made
| Agent | Model | Review type | Result |
|---|---|---|---|
| claude | claude-opus-5-5 | test-gap | 3 found, 4 posted |
| claude | claude-opus-5-5 | rust | 1 found, 1 posted |
| claude | claude-opus-5-5 | golang | 0 found, 0 posted |
| codex | gpt-5.6-terra | test-gap | 2 found, 1 posted |
| codex | gpt-5.6-terra | rust | 0 found, 0 posted |
| codex | gpt-5.6-terra | golang | 0 found, 0 posted |
Synthesis: claude-sonnet-5-5 merged the findings, removed duplicates and dropped findings it could not confirm in the code. 1 posted finding(s) were raised by two or more models.
Plain language: claude-sonnet-5-5 read every comment as a new reader would. 1 comment(s) had a problem that stopped the reader acting; it rewrote 1.
Stack: position 2 of 3 (:asterisk: #532, #534, #557). *️⃣ marks this pull request.
Context loaded: the description, 1 linked issue(s) and 5 discussion entries.
…key is copied out and freed unwiped FrameMap kept a HashSet<String> copy of every key it wrote, which was freed without a wipe. It affected plaintext frames from to_frame as well as the transport serde format (zeroize audit, #523, ZA-0007). A repeat is now found at the map's end by sorting the keys' ranges in the frame. Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
…, and return transport frames Protected The transport Serializer and KeySerializer used serde's default collect_str, which formats into a String it frees unwiped (#523, ZA-0021, ZA-0022). They now format into a buffer that grows as the frame does and is wiped, using the reserve and Appender the storage writer already had, moved to the shared module. serde::to_vec now returns Protected<Vec<u8>>, since what a host sends may be a credential even when it is not plaintext to seal (ZA-0019). BREAKING CHANGE: vitaminc_aead_frame::serde::to_vec returns Protected<Vec<u8>> instead of Vec<u8>. Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
…e writes leaves a key or value in freed memory Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
… why they are not wiped Claude-Session: https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE
…pty key is an empty range in the frame
…iling Display wipe, and its repeat refusal, which no test pinned down
fb57376 to
b28f087
Compare
Closes #523. Stacked on #557.
I ran the Trail of Bits
zeroize-auditskill over the storage and transport codecs invitaminc-aead-frame(storage.rs,serde.rs,transport.rs) and the frame writer they share invitaminc-aead(frame.rs). It covered:This PR fixes what the audit confirmed, and records why the rest is acceptable.
What the audit confirmed
FrameMap::entrycopied every map key into aHashSet<String>that was freed unwiped. This affects plaintext frames fromto_frameas well as transport serde.Serializerused serde's defaultcollect_str, which formats into aStringit frees unwiped.KeySerializer(map keys).serde::to_vechanded back a plainVec<u8>, which the caller frees unwiped.Every wipe survives optimization. There are no OPTIMIZED_AWAY_ZEROIZE findings:
reservehelpers (storage andvitaminc_aead::frame) wipe the old allocation with volatile stores before__rust_dealloc, at O0 through O3, in IR and in assembly.Zeroizingdrops in bothto_vecs, and theProtecteddrop, survive the same way.The other 18 PoCs show their findings are not exploitable. These were weak tool matches:
From/Intoimpls;derive(Copy)on a fieldless enum and on a borrowed slice;Drop" on types that own nothing.The fixes
FrameMap(vitaminc-aead): it keeps each key's range in the frame instead of a copy, and finds a repeat atend()by sorting those ranges. A repeated key is now refused when the map ends, not at the secondentry(). The error is the sameDuplicateKey. A new unit test covers a repeat that isn't adjacent, keys that start other keys, and the same key in a nested map.collect_str: the transportSerializerandKeySerializeroverride it and format into aZeroizingbuffer that grows the way frames do. ThereserveandAppenderhelpers moved fromstorage.rstoserde.rs, which both writers now share.serde::to_vecreturnsProtected<Vec<u8>>, likestorage::to_vecandto_frame. The module says this format isn't for plaintext to seal, but it also names "a credential in a configuration" as data a host may send. Only this crate's tests and doc example called it. The transport format promises no stability, so this is allowed in any release, but it is a breaking API change.tests/transport_wipe.rs, built ontests/storage_wipe.rs's inspecting allocator. It checks transportto_vecandto_framethrough buffer growth, map keys,Displaykeys and values, a refused repeat, a failed write and the dropped frame. It fails with either fix reverted: 64 copies left without theFrameMapfix, and 64 without thecollect_stroverride.Accepted, and recorded
STORAGE.md's Memory and timing and the new Memory section in the transport module's doc record these:
Stack and register copies are not wiped (ZA-0023 to ZA-0026, ZA-0028, ZA-0029, all low):
char's UTF-8 while it is written;distinct;visit_i128/visit_u128format into.Rust has no way to reach every copy the compiler makes, and wiping the one copy the source names would claim more than it gives.
core::fmt's own buffers (ZA-0027): the heap check showed nothing left. The stack check was rejected in review: it ran too late, in a debug build, so it can't show the stack was wiped.Caveats on the audit itself
transport.rshas no findings. Ciphertext map keys and passthrough values are in the clear by design.Testing
cargo test -p vitaminc-aead -p vitaminc-aead-frame --all-features, and the doctests: pass.cargo clippy --no-deps --all-targets --all-features -D warningsand rustdoc with-D warnings: clean.cargo mutants --in-diffon this diff: 34 mutants, 23 caught, 11 unviable, 0 missed.https://claude.ai/code/session_01EnoQSYdggmUYkqNyahDchE