From 723a77a18eed9f33ad67520864d68198a712a751 Mon Sep 17 00:00:00 2001 From: qlrd Date: Mon, 1 Jun 2026 11:54:34 -0300 Subject: [PATCH] test: add `amnesia` tests to crate kdk-zeroize The proposed tests aim to restrict some audit gap called out in `tests/sensitive.rs`. --- Cargo.lock | 58 ++++++++++++++++ crates/kdk-zeroize/Cargo.toml | 3 + .../kdk-zeroize/examples/wipe_vs_zeroize.rs | 67 +++++++++++++++++++ crates/kdk-zeroize/src/lib.rs | 4 +- crates/kdk-zeroize/src/sensitive.rs | 25 ++++++- crates/kdk-zeroize/src/wipe.rs | 23 ------- crates/kdk-zeroize/tests/amnesia.rs | 57 ++++++++++++++++ crates/kdk-zeroize/tests/sensitive.rs | 12 +--- 8 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 crates/kdk-zeroize/examples/wipe_vs_zeroize.rs delete mode 100644 crates/kdk-zeroize/src/wipe.rs create mode 100644 crates/kdk-zeroize/tests/amnesia.rs diff --git a/Cargo.lock b/Cargo.lock index 9e2ccf13..1f3144da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,3 +5,61 @@ version = 4 [[package]] name = "kdk-zeroize" version = "0.0.1" +dependencies = [ + "zeroize", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/kdk-zeroize/Cargo.toml b/crates/kdk-zeroize/Cargo.toml index 7b424a8d..5493cb83 100644 --- a/crates/kdk-zeroize/Cargo.toml +++ b/crates/kdk-zeroize/Cargo.toml @@ -4,3 +4,6 @@ version = "0.0.1" edition.workspace = true license.workspace = true description = "Sensitive-byte primitives for KDK" + +[dev-dependencies] +zeroize = { version = "1", features = ["derive"] } diff --git a/crates/kdk-zeroize/examples/wipe_vs_zeroize.rs b/crates/kdk-zeroize/examples/wipe_vs_zeroize.rs new file mode 100644 index 00000000..10838b63 --- /dev/null +++ b/crates/kdk-zeroize/examples/wipe_vs_zeroize.rs @@ -0,0 +1,67 @@ +//! Integration test with `zeroize` crate. +//! +//! Run with: +//! ```text +//! cargo run --example wipe_vs_zeroize -p kdk-zeroize --release +//! ``` +//! +//! `--release` is intentional: that's where the optimiser is most +//! aggressive about dead-store elimination. + +use kdk_zeroize::wipe_in_place_mut; +use zeroize::Zeroize; + +fn case(label: &str, mut a: [u8; N], mut b: [u8; N]) { + assert_eq!(a, b, "{label}: inputs differ pre-wipe"); + unsafe { wipe_in_place_mut(&mut a) }; + b.zeroize(); + assert_eq!(a, [0u8; N], "{label}: KDK didn't zero"); + assert_eq!(b, [0u8; N], "{label}: zeroize didn't zero"); + assert_eq!(a, b, "{label}: KDK and zeroize disagree"); + println!(" {label}: OK"); +} + +fn main() { + println!("kdk_zeroize::wipe_in_place_mut vs zeroize::Zeroize::zeroize"); + + case("[u8; 32] all 0xAA", [0xAAu8; 32], [0xAAu8; 32]); + case("[u8; 64] all 0xFF", [0xFFu8; 64], [0xFFu8; 64]); + + let mut non_uniform = [0u8; 64]; + for (i, x) in non_uniform.iter_mut().enumerate() { + *x = (i as u8).wrapping_add(0xA5); + } + case("[u8; 64] non-uniform", non_uniform, non_uniform); + + // From here we will start a lot the `#[repr(C)]` attribute + #[repr(C)] + #[derive(Copy, Clone, PartialEq, Eq, Debug, Zeroize)] + struct Foo { + a: u32, + b: u64, + c: [u8; 8], + } + let init = Foo { + a: 0xDEADBEEF, + b: 0xCAFEBABE_F00DBABE, + c: [0xFFu8; 8], + }; + let zero = Foo { + a: 0, + b: 0, + c: [0u8; 8], + }; + + let mut kdk_foo = init; + unsafe { wipe_in_place_mut(&mut kdk_foo) }; + assert_eq!(kdk_foo, zero, "KDK struct wipe"); + + let mut zeroize_foo = init; + zeroize_foo.zeroize(); + assert_eq!(zeroize_foo, zero, "zeroize struct wipe"); + + assert_eq!(kdk_foo, zeroize_foo, "struct: KDK and zeroize disagree"); + println!(" Foo (struct): OK"); + + println!("PASS: KDK and zeroize produce byte-identical output across all cases."); +} diff --git a/crates/kdk-zeroize/src/lib.rs b/crates/kdk-zeroize/src/lib.rs index 1b5f7865..de9a50b7 100644 --- a/crates/kdk-zeroize/src/lib.rs +++ b/crates/kdk-zeroize/src/lib.rs @@ -22,7 +22,5 @@ #![no_std] mod sensitive; -mod wipe; -pub use sensitive::SensitiveBytes; -pub use wipe::wipe_in_place_mut; +pub use sensitive::{wipe_in_place_mut, SensitiveBytes}; diff --git a/crates/kdk-zeroize/src/sensitive.rs b/crates/kdk-zeroize/src/sensitive.rs index d03dde25..90a2c8d7 100644 --- a/crates/kdk-zeroize/src/sensitive.rs +++ b/crates/kdk-zeroize/src/sensitive.rs @@ -1,7 +1,6 @@ use core::fmt; use core::marker::PhantomData; - -use crate::wipe_in_place_mut; +use core::sync::atomic; /// `N` is the byte length and `O` is a chosen zero-sized type that lets the /// type system track where the bytes came from (BIP39 seed, dice entropy, AES @@ -25,6 +24,28 @@ pub struct SensitiveBytes { _origin: PhantomData, } +/// Caller must ensure that after this function returns, **no code +/// reads `*value` as a `T`**. +/// +/// # Safety +/// +/// Zeroing the bytes may produce an invalid `T` (niches, validity invariants +/// on inner fields, etc.). Typically used inside `Drop`, where Rust's drop +/// semantics already guarantee no further reads. +/// +/// WARN: authorship is from [@luisschwab](https://github.com/luisschwab) +/// while reading a go discord's topic. It's improvised and need auction. +pub unsafe fn wipe_in_place_mut(value: &mut T) { + let ptr = (value as *mut T).cast::(); + let len = core::mem::size_of::(); + for i in 0..len { + unsafe { + core::ptr::write_volatile(ptr.add(i), 0); + } + atomic::compiler_fence(atomic::Ordering::SeqCst); + } +} + impl SensitiveBytes { /// Wrap a `[u8; N]` as origin-tagged sensitive bytes. pub const fn new(bytes: [u8; N]) -> Self { diff --git a/crates/kdk-zeroize/src/wipe.rs b/crates/kdk-zeroize/src/wipe.rs deleted file mode 100644 index 4891074c..00000000 --- a/crates/kdk-zeroize/src/wipe.rs +++ /dev/null @@ -1,23 +0,0 @@ -use core::sync::atomic; - -/// Caller must ensure that after this function returns, **no code -/// reads `*value` as a `T`**. -/// -/// # Safety -/// -/// Zeroing the bytes may produce an invalid `T` (niches, validity invariants -/// on inner fields, etc.). Typically used inside `Drop`, where Rust's drop -/// semantics already guarantee no further reads. -/// -/// WARN: authorship is from [@luisschwab](https://github.com/luisschwab) -/// while reading a go discord's topic. It's improvised and need auction. -pub unsafe fn wipe_in_place_mut(value: &mut T) { - let ptr = (value as *mut T).cast::(); - let len = core::mem::size_of::(); - for i in 0..len { - unsafe { - core::ptr::write_volatile(ptr.add(i), 0); - } - atomic::compiler_fence(atomic::Ordering::SeqCst); - } -} diff --git a/crates/kdk-zeroize/tests/amnesia.rs b/crates/kdk-zeroize/tests/amnesia.rs new file mode 100644 index 00000000..eb073465 --- /dev/null +++ b/crates/kdk-zeroize/tests/amnesia.rs @@ -0,0 +1,57 @@ +//! Amnesia verification — proves `wipe_in_place_mut` zeroes the bytes +//! of whatever `&mut T` you hand it. +//! +//! `SensitiveBytes::drop` is a one-line delegation +//! (`unsafe { wipe_in_place_mut(self) }`), so the Drop-time wipe is +//! guaranteed by *reading the source*, not by a test. These tests +//! prove the primitive itself. + +use kdk_zeroize::{wipe_in_place_mut, SensitiveBytes}; + +enum AesKey {} + +#[test] +fn wipe_in_place_mut_zeroes_u8_array_32() { + let mut buf = [0xAAu8; 32]; + unsafe { wipe_in_place_mut(&mut buf) }; + assert_eq!(buf, [0u8; 32]); +} + +#[test] +fn wipe_in_place_mut_zeroes_u8_array_64_non_uniform() { + let mut buf = [0u8; 64]; + for (i, b) in buf.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(0xA5); + } + unsafe { wipe_in_place_mut(&mut buf) }; + assert_eq!(buf, [0u8; 64]); +} + +#[test] +fn wipe_in_place_mut_zeroes_arbitrary_struct() { + #[repr(C)] + struct Foo { + a: u32, + b: u64, + c: [u8; 8], + } + let mut foo = Foo { + a: 0xDEADBEEF, + b: 0xCAFEBABE_F00DBABE, + c: [0xFFu8; 8], + }; + assert_ne!(foo.a, 0); + assert_ne!(foo.b, 0); + assert_ne!(foo.c, [0u8; 8]); + unsafe { wipe_in_place_mut(&mut foo) }; + assert_eq!(foo.a, 0); + assert_eq!(foo.b, 0); + assert_eq!(foo.c, [0u8; 8]); +} + +#[test] +fn wipe_in_place_mut_zeroes_sensitive_bytes() { + let mut value = SensitiveBytes::<32, AesKey>::new([0xDEu8; 32]); + unsafe { wipe_in_place_mut(&mut value) }; + assert_eq!(value.expose_secret(), &[0u8; 32]); +} diff --git a/crates/kdk-zeroize/tests/sensitive.rs b/crates/kdk-zeroize/tests/sensitive.rs index 33eeb540..06de5f0c 100644 --- a/crates/kdk-zeroize/tests/sensitive.rs +++ b/crates/kdk-zeroize/tests/sensitive.rs @@ -1,13 +1,7 @@ -//! The kdk-zeroize guarantee can't be observed from outside the -//! process. +//! Behavioural tests: `expose_secret`, `Debug` redaction, origin markers, etc. //! -//! Rust's memory model doesn't expose dropped storage so these tests are an -//! attempt to observe how probably a pin could occurs on through a -//! `expose_secret`, `Debug` redaction, `Drop` runs without panic, -//! `as_slice` agreement, etc., rather than the wipe itself. -//! -//! The wipe correctness is delegated to whoever wants to audit this crate and -//! we be welcomed through a [`SECURITY.md`] report. +//! Drop-time wipe proof lives in `tests/amnesia.rs` (`Box::into_raw` → +//! `drop_in_place` → volatile byte check → `dealloc`). use kdk_zeroize::SensitiveBytes; enum AesKey {}