Skip to content

fix(protected): zeroize controlled types on drop (#181)#185

Open
coderdan wants to merge 4 commits into
mainfrom
fix/protected-zeroize-on-drop
Open

fix(protected): zeroize controlled types on drop (#181)#185
coderdan wants to merge 4 commits into
mainfrom
fix/protected-zeroize-on-drop

Conversation

@coderdan
Copy link
Copy Markdown
Contributor

Closes #181.

Problem

vitaminc_protected::{Protected, Equatable, Exportable} derived Zeroize and (for Protected) asserted the ZeroizeOnDrop marker, but had no Drop impl — the marker is a contract claim, not enforcement. Dropping a Protected<Vec<u8>> freed the heap buffer without wiping it; Protected<[u8; N]> was never touched. The chain-of-custody threaded through aead/encrypt was correct in shape but delivered no actual leaf wipe.

Fix

  • Real ZeroizeOnDrop on all three types (#[derive(…, ZeroizeOnDrop)]), which requires a T: Zeroize bound on each struct (a conditional Drop must match the struct bounds — E0367).
  • into_inner_unchecked (ptr::read + mem::forget) so risky_unwrap / flatten / transpose still move the secret out to the caller without the new Drop wiping the value the caller now owns.
  • Controlled: ControlledPrivate + Zeroize supertrait — a true invariant ("every controlled type wraps zeroizable material") that collapses most of the bound cascade; every where T: Controlled impl gets Zeroize for free. Usage gains a delegating Zeroize impl to satisfy it.
  • Downstream cascade: random's Generatable impls carry the bound; permutation::PermutationKey loses Copy and its borrow-then-consume call sites clone explicitly.

⚠️ Breaking changes

Copy is removed from Protected, Exportable, and PermutationKey. Copy and Drop are mutually exclusive — and a bitwise copy of a secret would leave un-zeroized duplicates, defeating the fix. Code that copied these by value must now .clone() explicitly (a deliberate duplication of a secret). Equatable never implemented Copy, so it is unaffected.

Validation (/zeroize-audit)

  • Source (trait-aware semantic pass): 0 findings — all three types are now correctly ZeroizeOnDrop.
  • Runtime: new drop_zeroizes_inner test observes (via a flag-setting Zeroize inner) that Drop runs the wipe; risky_unwrap_does_not_zeroize confirms the move-out path hands back a live value.
  • -O2 LLVM IR (the issue's acceptance criterion): encrypt's optimized IR now contains a core::ptr::drop_in_place monomorphization plus the key/decrypt paths with 168 store volatile i8 0 + 168 seq_cst fences — the zeroize wipes fire and are not DSE-removable. Before this change there were zero.

Test status

Whole workspace compiles; clippy --all-targets -D warnings clean; cargo fmt --check clean. All tests pass except the pre-existing localstack-dependent vitaminc-kms::test_finalize (connection refused to 127.0.0.1:4566), unrelated to this change.

Follow-up

The 3 mem::forget hits the audit's grep-based scanner flags in into_inner_unchecked are false positives — the paired ptr::read transfers ownership to the caller; forgetting the husk prevents a double-wipe (pinned by risky_unwrap_does_not_zeroize).

Comment thread packages/permutation/src/key.rs Outdated
@coderdan
Copy link
Copy Markdown
Contributor Author

coderdan commented Jun 6, 2026

Follow-up: swept the PR for the "clone/copy a secret instead of moving it" pattern

Removing Copy (for the zeroizing Drop) surfaces methods that took self by value but only borrowed it — under Copy that was invisible, and the reflexive fix is .clone(), which silently re-introduces the secret duplication this PR exists to eliminate. I went through all 9 changed files for that shape. Two genuine instances, both fixed:

1. permutation/key.rsinvert (commit beb47cd)

invert(self) only ever borrowed internally (depermute_array takes &PermutationKey and builds a fresh key), so complement had to .clone() the borrowed target to get an owned value to consume.

  • invert(self)invert(&self) (it never needed ownership)
  • complement drops the clone: Self(target.invert().0.map(|arr| permute_array(self, arr)))
  • test simplifies key.clone().invert()key.invert()

No key copy is ever made. These were the only two .invert() call sites in the repo.

2. protected/mod.rsflatten_array (commit b4dba74)

Built its output with [Default::default(); N] + out[i] = *x.risky_ref(), copying each inner secret out of a borrowed element (hence T: Copy + Default).

  • now Protected::new(array.map(|p| p.risky_unwrap()))[_; N]::map moves each Protected<T> through risky_unwrap, so the inner value is moved, never copied
  • drops the Copy / Default bounds (resolves the existing // TODO: Default won't be needed…) and removes the transient per-element duplication; also broadens the fn to non-Copy secrets

Everything else checked out

  • into_inner_unchecked (ptr::read + mem::forget) in protected/equatable/exportable is the correct move-out idiom — no double-free, no use-after-zeroize.
  • The only other added copy is the pre-existing .copied() in Controlled::iter (copies Copy elements into Protected<I> by design) — not a regression.
  • lib.rs / generatable.rs / zeroed.rs / usage.rs / controlled.rs are mechanical T: Zeroize bound propagation from the new supertrait — correct.

Tests green: vitaminc-protected and vitaminc-permutation (incl. the new drop_zeroizes_inner / risky_unwrap_does_not_zeroize regression tests), fmt clean.

General pattern for reviewers: when Copy is removed from a secret type, prefer changing fn foo(self)&self (or moving via .map/risky_unwrap) over reaching for .clone().

coderdan added 4 commits June 6, 2026 16:27
Protected/Equatable/Exportable derived Zeroize and asserted the
ZeroizeOnDrop marker but had no Drop impl, so the inner secret was never
wiped when the wrapper went out of scope.

- Add ZeroizeOnDrop (real Drop) to all three types; this requires a
  T: Zeroize bound on each struct (a conditional Drop must match the
  struct bounds, E0367).
- Remove Copy from Protected/Exportable — Copy and Drop are mutually
  exclusive, and a bitwise copy would leave un-zeroized duplicates of the
  secret. Callers that need a duplicate now use explicit Clone.
- Add into_inner_unchecked (ptr::read + mem::forget) so risky_unwrap /
  flatten / transpose still move the secret out to the caller without the
  new Drop wiping the moved-out value.
- Make Controlled: Zeroize a supertrait — a true invariant that collapses
  most of the bound cascade (every T: not found
Controlled not found impl gets Zeroize
  for free); Usage gains a delegating Zeroize impl to satisfy it.
- Fix flatten_array (silently relied on Copy) and a doctest likewise.

Verified with /zeroize-audit: trait-aware source pass is clean, and an
-O2 IR probe shows Protected<[u8;32]>/Protected<Vec<u8>> drop glue emits
volatile zero-stores + seq_cst fences (not DSE-removable). Regression
tests drop_zeroizes_inner and risky_unwrap_does_not_zeroize added.

Refs #181


Downstream cascade from the Protected/Equatable/Exportable zeroize-on-drop
fix:

- random: the Generatable impls for Protected/Equatable/Exportable now
  carry the T: Zeroize bound the wrapper types require.
- permutation: PermutationKey wraps Exportable<Protected<[u8; N]>>, a
  secret that now zeroizes on drop, so it can no longer derive Copy
  (Copy/Drop are exclusive, and a bitwise copy would leave un-zeroized key
  duplicates). Call sites that consumed a borrowed key (complement, the
  invert test) now clone explicitly.

Refs #181
invert() took self by value but only ever borrowed it internally
(depermute_array takes &PermutationKey, building a fresh key). Under the
old Copy impl this was invisible; removing Copy for the zeroizing Drop
surfaced it, and complement() had reached for .clone() to get an owned
value to consume — re-introducing exactly the secret duplication this PR
removes Copy to prevent.

Change invert() to &self (it never needed ownership) and drop the clone
in complement() and the inversion test. The .0.map() already threads the
inner array through by move, so no copy of the key is ever made.
flatten_array built its output with `[Default::default(); N]` + `*x.risky_ref()`,
copying each inner secret out of a borrowed element — requiring `T: Copy + Default`
and duplicating the secret per element. Use `[_; N]::map` to move each
`Protected<T>` through `risky_unwrap`, so the inner value is moved (never copied)
into the result. Drops the `Copy`/`Default` bounds (resolving the existing TODO)
and removes the transient secret duplication, consistent with this PR removing
`Copy` from the controlled types.
@coderdan coderdan force-pushed the fix/protected-zeroize-on-drop branch from b4dba74 to 36cc1d3 Compare June 6, 2026 08:29
@coderdan coderdan requested a review from Copilot June 6, 2026 08:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a security/correctness gap in vitaminc_protected where Protected, Exportable, and Equatable claimed ZeroizeOnDrop behavior without actually zeroizing on drop, and updates downstream code to satisfy the new T: Zeroize bounds and non-Copy semantics.

Changes:

  • Implement real zeroize-on-drop behavior by deriving ZeroizeOnDrop and pushing T: Zeroize bounds onto the controlled wrapper types.
  • Add move-out helpers (into_inner_unchecked) and refactor risky_unwrap / flatten / transpose to preserve “move the secret out” semantics under the new Drop.
  • Propagate required bounds and Copy removals through downstream crates (random, permutation) and add a Zeroize impl for Usage.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/random/src/generatable.rs Adds T: Zeroize bounds for Generatable impls over controlled wrappers.
packages/protected/src/zeroed.rs Requires Zeroize for Zeroed impls of controlled wrappers to match new invariants.
packages/protected/src/usage/mod.rs Adds Zeroize for Usage and tightens Acceptable bound for Protected.
packages/protected/src/protected/mod.rs Derives ZeroizeOnDrop, adds into_inner_unchecked, updates helpers, and adds regression tests.
packages/protected/src/lib.rs Tightens ReplaceT and sealing bounds to align with T: Zeroize requirements.
packages/protected/src/exportable/mod.rs Derives ZeroizeOnDrop, adds into_inner_unchecked, removes Copy semantics, updates unwrap path.
packages/protected/src/equatable/mod.rs Derives ZeroizeOnDrop, adds into_inner_unchecked, updates unwrap path and bounds.
packages/protected/src/controlled.rs Makes Controlled: Zeroize and updates iterator constraints/construction.
packages/permutation/src/key.rs Removes Copy from PermutationKey and adjusts APIs to borrow instead of consume where possible.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +25 to +32
fn into_inner_unchecked(self) -> T {
// SAFETY: `self.0` is read exactly once, then `self` is forgotten so its
// `Drop` never runs against the now-moved-out field (no double-free, no
// use-after-zeroize).
let inner = unsafe { core::ptr::read(&self.0) };
core::mem::forget(self);
inner
}
Comment on lines +71 to +77
fn into_inner_unchecked(self) -> T {
// SAFETY: `self.0` is read once, then `self` is forgotten so its `Drop`
// does not also wipe the moved-out value.
let inner = unsafe { core::ptr::read(&self.0) };
core::mem::forget(self);
inner
}
Comment on lines +113 to +119
fn into_inner_unchecked(self) -> T {
// SAFETY: `self.0` is read once, then `self` is forgotten so its `Drop`
// does not also wipe the moved-out value.
let inner = unsafe { core::ptr::read(&self.0) };
core::mem::forget(self);
inner
}
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.

Protected<T> does not actually zeroize on drop (Drop impl missing)

2 participants