Skip to content

fix(normalize,ore): close the plaintext copies the zeroize re-audit of #489 found - #492

Open
coderdan wants to merge 3 commits into
fix/ore-int-stack-copyfrom
fix/zeroize-reaudit-residuals
Open

coderdan wants to merge 3 commits into
fix/ore-int-stack-copyfrom
fix/zeroize-reaudit-residuals

Conversation

@coderdan

Copy link
Copy Markdown
Contributor

This closes the plaintext copies that the zeroize re-audit of #489 found in vitaminc-normalize and vitaminc-ore. It stacks on #489 and should merge after it.

No encoded byte changes and no public API changes. The tests that check literal bytes, and the tests that check against orderable-bytes, pass without changes.

What changed

Finding Where What changed Result
ZA-0102 normalize test helper holds The closure moves the plain Vec that zip_ref hands it into a zeroize::Zeroizing, so the copy is wiped when the closure ends. zeroize is now a dev-dependency of vitaminc-normalize. Removed
ZA-0103 normalize test helper decimal::tests::encode The bytes are copied into the array through update, which borrows the vector. The vector stays in its Protected and is wiped when it drops. The returned array of test bytes is plain, and a comment says why that is fine. Removed
ZA-0104 normalize integer The sign and word are worked out inside the closure that writes the protected buffer, rather than by calling Int::sign_and_word. Only the width is branched on, as before. A new test checks that the result always matches sign_and_word. Removed
ZA-0105 normalize timestamp The nanosecond count is worked out in place, the same way timestamp_nanos does, rather than by calling it. A new test checks that both accept the same counts. Removed in release. In the audit's mixed build, one slot is left, explained below.
ZA-0106 ore order() / int() convert is folded into int, which now takes the target and the value, converts, and writes the bytes in one step. No Int at the target width is built and passed by pointer. Removed

The audit said that removing ZA-0104 to ZA-0106 would need unsafe code. It does not. Each slot came from a call that was not inlined and returned or took a 32-byte value through memory. Doing the work in the encoder removes the slot. No unsafe code and no volatile writes were added.

Compiler evidence (AArch64 only)

The builds used the commands from the audit's rust-compiler-analysis/notes.md:

  • release: RUSTFLAGS="--emit=llvm-ir,asm" CARGO_INCREMENTAL=0 cargo +nightly rustc -p <crate> --lib --release --all-features -- -C codegen-units=1
  • the audit's build, a dev profile with only the audited crate at opt-level 2: cargo +nightly rustc -p <crate> --lib --all-features -- --emit=llvm-ir,asm -C opt-level=2 -C codegen-units=1

ZA-0104, integer (release and the audit's build are the same)

Before:

%0 = alloca [32 x i8], align 16
call void @..Int13sign_and_word(ptr .. sret([32 x i8]) .. %0, ptr .. %value)
sub  sp, sp, #80
mov  x8, sp
bl   ..Int13sign_and_word
ldrb w22, [sp]
ldp  x21, x20, [sp, #16]
...
add  sp, sp, #80
ret

After: no alloca, and the value argument is now readonly captures(none). The frame holds only the saved registers:

stp x20, x19, [sp, #-32]!
stp x29, x30, [sp, #16]
bl  __rust_alloc_zeroed
...
ldp x20, x19, [sp], #32
ret

ZA-0105, timestamp

Before, in release and in the audit's build:

%_4 = alloca [32 x i8], align 16
call void @..timestamp_nanos(ptr .. sret([32 x i8]) .. %_4, i8 %unit, ptr .. )
sub sp, sp, #80
bl  ..timestamp_nanos
ldr w8, [sp]
ldp x21, x20, [sp, #16]

After, in release: no alloca, no sub sp. Only the saved registers are stored (stp x24, x23, [sp, #-64]! ... ldp x24, x23, [sp], #64; ret).

After, in the audit's build: one 32-byte slot is left. It is the Option<i128> that Int::to_i128 returns (%self1 = alloca [32 x i8], bl ..Int7to_i128, ldp x21, x22, [sp, #16]). It holds the count before it is turned into nanoseconds. It is there only because that build compiles vitaminc-aead at opt-level 0, so to_i128 is not inlined. In release, to_i128 is inlined and the slot is gone. The audit dropped the same slot in date and decimal as a side effect of that build (F-RUST-IR-0003), so it is left as it is here too.

ZA-0106, order() → int()

Before (release):

%val = alloca [32 x i8], align 16
call fastcc void @..order3int(ptr .. %bytes, ptr .. readonly .. dereferenceable(32) %val)
strb w10, [sp]
strb w14, [sp, #1]
strh w13, [sp, #2]
str  w12, [sp, #4]
stp  x11, x8, [sp, #8]
str  x9, [sp, #24]
mov  x1, sp
bl   ..order3int

After: int is inlined into order, and %val is gone in release and in the audit's build. The only stack stores left on the integer path are the Vec header of the returned buffer (capacity, pointer, length), not the value. In the audit's build, the to_i128 slots (%1, %_10.i) are still there, as they were before (%1, %_7.i). They are the same opt-level 0 side effect as above and are gone in release.

ZA-0102 and ZA-0103 (lib tests at opt-level 3)

In holds, the expected Vec now gets 8 store volatile i8 0 before __rust_dealloc. Before, it was freed with no wipe. In encode, the encoding's Vec is wiped by the Protected drop before it is freed. The 14-byte array that encode returns is a plain array of test bytes, as intended.

Not covered

  • x86-64 is not checked. All the IR and asm above is for aarch64-apple-darwin.
  • In order(), the date, timestamp and decimal paths still copy the plaintext Int into a 32-byte slot (%days, %count, %mantissa) to call vitaminc-normalize's public date, timestamp and decimal. These encoders take the Int by value, and the Rust calling convention passes a value that size through a copy in the caller's memory. The audit did not report these slots. Removing them would need those encoders to change signature, or be marked #[inline], so they are left for a follow-up. Inside vitaminc-normalize, normalize() no longer makes such copies in release: before this change it had four 32-byte slots (%i, %count, %1, %_4.i), and now it has none.
  • ZA-0101, which is that the Controlled combinators in vitaminc-protected hand the closure the bare inner value, will be fixed in a separate, later PR. This PR does not change vitaminc-protected. The holds fix uses the same idea that PR is expected to build in, which is to give the closure a wrapper that wipes.

… Protected, so the tests show the safe way to compare

holds() got the expected bytes as a plain Vec from zip_ref and freed it
unwiped (ZA-0102); it now moves them into a Zeroizing inside the closure.
The decimal encode() helper unwrapped the encoding and freed its Vec
unwiped (ZA-0103); it now copies the bytes out through a borrow, so the
Vec stays in its Protected and is wiped when it drops.
… nanoseconds in place, so no unwiped return slot holds them

integer() called Int::sign_and_word and timestamp() called
timestamp_nanos. Neither is inlined across the crate boundary, and each
returned the plaintext through a 32-byte slot on the encoder's stack that
nothing wiped (ZA-0104, ZA-0105). The encoders now compute these values
themselves, so in a release build they stay in registers until they are
written to the protected buffer. The encoded bytes do not change; new
tests check that both encoders agree with the aead functions they
replace.
… value is not left in an unwiped stack slot

order() converted the value to an Int at the target's width and passed
it to int() by pointer, through a 32-byte slot on its stack that nothing
wiped (ZA-0106). int() now takes the target and does the conversion
itself, so the converted value stays in a register until it is written to
the protected buffer. The encoded bytes do not change.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T07:22:25.401836Z b39183f PR opened
🔒 Security Review ✅ Completed 2026-10-20T07:24:41.373819Z b39183f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

🧬 Mutation testing (cargo-mutants, --in-diff)

caught missed unviable timeout
42 0 20 0

✅ Every mutant in the changed lines was caught by a test.

@github-actions

Copy link
Copy Markdown
Contributor

✅ No CRAP threshold violations

1292 function(s) analyzed · threshold 30

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant