zero_secrets/lib.rs
1//! Simple lightweight secret wrappers that zeroize on drop
2//! and prevent accidental logging. Built on RustCrypto [`zeroize`](https://crates.io/crates/zeroize)
3//!
4//! This crate provides three owned wrappers around in-memory secret material,
5//! backed by the `RustCrypto` [`zeroize`] crate:
6//!
7//! - [`SecretString`] owns a heap [`String`] (e.g. NKEY seeds, `.creds` text,
8//! invite/recovery codes, base64-encoded credentials).
9//! - [`SecretBytes`] owns a heap [`Vec<u8>`] (e.g. decrypted plaintext buffers,
10//! credential blobs, message bodies that may contain secrets).
11//! - [`SecretArray<N>`] owns a fixed-size stack `[u8; N]` (e.g. X25519 private
12//! seeds, AEAD keys, shared secrets — typically `[u8; 32]`).
13//!
14//! # Guarantees
15//!
16//! Every wrapper:
17//!
18//! 1. Zeroizes its backing storage on [`Drop`] (heap allocation contents for
19//! the string/bytes wrappers, the stack bytes for [`SecretArray`]).
20//! 2. Redacts [`Debug`]. `Display` is not implemented to prevent `{}` formatting.
21//! Secret contents or length are not printed.
22//! 3. Produces an **independently owned** value on [`Clone`] — a deep copy with
23//! its own allocation (for the heap types) whose own [`Drop`] also zeroizes.
24//! This holds for clones moved into async blocks/tasks.
25//!
26//! # Ownership contract
27//!
28//! - **Borrow in:** callers wrap only when *this* code takes ownership of a
29//! secret. Code that merely reads a secret should accept `&str` / `&[u8]`.
30//! - **Wrap on ownership:** construct via `new` or the `From` impls when an
31//! owned secret enters the crate that owns it.
32//! - **Borrow for use:** read through [`expose_secret`](SecretString::expose_secret)
33//! (returns `&str` / `&[u8]`); the borrow never escapes the wrapper.
34//! - **Plain owned out:** when ownership must *leave* the library, use the
35//! `#[must_use]` extractors ([`into_string`](SecretString::into_string),
36//! [`into_vec`](SecretBytes::into_vec), [`into_inner`](SecretArray::into_inner)).
37//! These return the plain owned value and intentionally do **not** zeroize the
38//! moved-out value — the caller then owns leak prevention. Their inputs are
39//! consumed, so no wrapped copy lingers.
40//!
41//! # How to use (recommended policy)
42//!
43//! These wrappers are designed to be **internal** to your crate, used to protect
44//! private owned fields. Keep `Secret*` types out of your public API:
45//! accept `&str` / `&[u8]` in inputs, and return borrowed accessors via `expose_secret`
46//! for normal use. Use plain owned extractors only when ownership must leave the boundary.
47//! `serde::Serialize` / `Deserialize` are intentionally not implemented. If serializing
48//! is needed, it should be in targeted, auditable call-sites.
49//!
50
51use core::fmt;
52use zeroize::{Zeroize, ZeroizeOnDrop};
53
54/// Marker rendered by [`Debug`] in place of any secret contents.
55const REDACTED: &str = "REDACTED";
56
57/// An owned, zeroizing wrapper around a [`String`].
58///
59/// The backing allocation is zeroized on [`Drop`]. [`Debug`] is redacted.
60/// [`Display`] is not implemented to prevent accidental formatting.
61/// [`Clone`] produces an independently owned, independently zeroized copy.
62#[derive(Clone, Zeroize, ZeroizeOnDrop)]
63pub struct SecretString(String);
64
65impl SecretString {
66 /// Wraps an owned [`String`] as a secret.
67 #[inline]
68 #[must_use]
69 pub const fn new(value: String) -> Self {
70 Self(value)
71 }
72
73 /// Borrows the secret as a `&str` for use without taking ownership.
74 ///
75 /// All uses of this function should be carefully reviewed to ensure the value
76 /// is not printed or copied to a non-Secret-wrapped storage.
77 /// The borrow does not change ownership and the secret is still zeroed on drop.
78 #[inline]
79 #[must_use]
80 pub fn expose_secret(&self) -> &str {
81 &self.0
82 }
83
84 /// Length of the secret in bytes.
85 #[inline]
86 #[must_use]
87 pub const fn len(&self) -> usize {
88 self.0.len()
89 }
90
91 /// Whether the secret is empty.
92 #[inline]
93 #[must_use]
94 pub const fn is_empty(&self) -> bool {
95 self.0.is_empty()
96 }
97
98 /// Consumes the wrapper and returns the plain owned [`String`].
99 ///
100 /// This is the ownership-handoff path for when a secret must leave the
101 /// library boundary. The moved-out value is **not** zeroized (that is the
102 /// point of handoff); the caller then owns leak prevention. No wrapped copy
103 /// remains, because `self` is consumed by value.
104 #[inline]
105 #[must_use]
106 pub fn into_string(mut self) -> String {
107 core::mem::take(&mut self.0)
108 }
109}
110
111impl From<String> for SecretString {
112 #[inline]
113 fn from(value: String) -> Self {
114 Self::new(value)
115 }
116}
117
118impl fmt::Debug for SecretString {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 write!(f, "SecretString({REDACTED})")
121 }
122}
123
124/// An owned, zeroizing wrapper around a [`Vec<u8>`].
125///
126/// The backing allocation is zeroized on [`Drop`]. [`Debug`]
127/// are redacted. [`Clone`] produces an independently owned, independently
128/// zeroized copy.
129#[derive(Clone, Zeroize, ZeroizeOnDrop)]
130pub struct SecretBytes(Vec<u8>);
131
132impl SecretBytes {
133 /// Wraps an owned [`Vec<u8>`] as a secret.
134 #[inline]
135 #[must_use]
136 pub const fn new(value: Vec<u8>) -> Self {
137 Self(value)
138 }
139
140 /// Borrows the secret as a `&[u8]` for use without taking ownership.
141 ///
142 /// All uses of this function should be carefully reviewed to ensure the value
143 /// is not printed or copied to a non-Secret-wrapped storage.
144 /// The borrow does not change ownership and the secret is still zeroed on drop.
145 #[inline]
146 #[must_use]
147 pub fn expose_secret(&self) -> &[u8] {
148 &self.0
149 }
150
151 /// Length of the secret in bytes.
152 #[inline]
153 #[must_use]
154 pub const fn len(&self) -> usize {
155 self.0.len()
156 }
157
158 /// Whether the secret is empty.
159 #[inline]
160 #[must_use]
161 pub const fn is_empty(&self) -> bool {
162 self.0.is_empty()
163 }
164
165 /// Consumes the wrapper and returns the plain owned [`Vec<u8>`].
166 ///
167 /// This is the ownership-handoff path for when a secret must leave the
168 /// library boundary. The moved-out value is **not** zeroized (that is the
169 /// point of handoff); the caller then owns leak prevention. No wrapped copy
170 /// remains, because `self` is consumed by value.
171 #[inline]
172 #[must_use]
173 pub fn into_vec(mut self) -> Vec<u8> {
174 core::mem::take(&mut self.0)
175 }
176}
177
178impl From<Vec<u8>> for SecretBytes {
179 #[inline]
180 fn from(value: Vec<u8>) -> Self {
181 Self::new(value)
182 }
183}
184
185impl fmt::Debug for SecretBytes {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 write!(f, "SecretBytes({REDACTED})")
188 }
189}
190
191/// An owned, zeroizing wrapper around a fixed-size `[u8; N]` stack array.
192///
193/// Intended for fixed-size key material such as X25519 private seeds, AEAD
194/// keys, and shared secrets (`[u8; 32]`). The stack bytes are overwritten with
195/// zeros on [`Drop`]. [`Debug`] is redacted. [`Clone`]
196/// produces an independent value that also zeroizes on its own drop.
197#[derive(Clone, Zeroize, ZeroizeOnDrop)]
198pub struct SecretArray<const N: usize>([u8; N]);
199
200impl<const N: usize> SecretArray<N> {
201 /// Wraps an owned `[u8; N]` as a secret.
202 #[inline]
203 #[must_use]
204 pub const fn new(value: [u8; N]) -> Self {
205 Self(value)
206 }
207
208 /// Borrows the secret as a `&[u8]` view.
209 ///
210 /// All uses of this function should be carefully reviewed to ensure the value
211 /// is not printed or copied to a non-Secret-wrapped storage.
212 /// The borrow does not change ownership and the secret is still zeroed on drop.
213 #[inline]
214 #[must_use]
215 pub const fn expose_secret(&self) -> &[u8] {
216 &self.0
217 }
218
219 /// Length of the array in bytes (always `N`).
220 #[inline]
221 #[must_use]
222 pub const fn len(&self) -> usize {
223 N
224 }
225
226 /// Whether the array is zero-length (`N == 0`).
227 #[inline]
228 #[must_use]
229 pub const fn is_empty(&self) -> bool {
230 N == 0
231 }
232
233 /// Consumes the wrapper and returns the plain owned `[u8; N]`.
234 ///
235 /// This is the ownership-handoff path for when key material must leave the
236 /// library boundary. The moved-out value is **not** zeroized (that is the
237 /// point of handoff); the caller then owns leak prevention. No wrapped copy
238 /// remains, because `self` is consumed by value.
239 ///
240 /// Note: `[u8; N]` is `Copy`, so this returns a bit-for-bit copy and the
241 /// (now-moved) `self` is dropped — its storage is the same bytes the copy
242 /// carries, so this does not zero the returned value.
243 #[inline]
244 #[must_use]
245 pub const fn into_inner(self) -> [u8; N] {
246 // `self` is `Copy`-backed; read the bytes out then forget the wrapper so
247 // its `Drop` does not run (which would be a no-op on this copy anyway,
248 // but forgetting keeps the handoff semantics explicit and avoids a
249 // redundant zeroize of a value we are about to return).
250 let inner = self.0;
251 core::mem::forget(self);
252 inner
253 }
254}
255
256impl<const N: usize> From<[u8; N]> for SecretArray<N> {
257 #[inline]
258 fn from(value: [u8; N]) -> Self {
259 Self::new(value)
260 }
261}
262
263impl<const N: usize> fmt::Debug for SecretArray<N> {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 write!(f, "SecretArray({REDACTED})")
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::{SecretArray, SecretBytes, SecretString};
272
273 // Compile-time assertions that the wrappers implement the zeroize traits.
274 const fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
275 const fn assert_traits() {
276 assert_zeroize_on_drop::<SecretString>();
277 assert_zeroize_on_drop::<SecretBytes>();
278 assert_zeroize_on_drop::<SecretArray<32>>();
279 }
280 const _: () = assert_traits();
281
282 // ---- SecretString ----
283
284 #[test]
285 fn secret_string_round_trip() {
286 let s = SecretString::new("hunter2".to_string());
287 assert_eq!(s.expose_secret(), "hunter2");
288 assert_eq!(s.len(), 7);
289 assert!(!s.is_empty());
290 assert!(SecretString::from(String::new()).is_empty());
291 assert_eq!(s.into_string(), "hunter2");
292 }
293
294 #[test]
295 fn secret_string_from() {
296 let s: SecretString = "abc".to_string().into();
297 assert_eq!(s.expose_secret(), "abc");
298 }
299
300 #[test]
301 fn secret_string_debug_redacted() {
302 let secret = "super-secret-seed-value";
303 let s = SecretString::new(secret.to_string());
304 let dbg = format!("{s:?}");
305 assert_eq!(dbg, "SecretString(REDACTED)");
306 assert!(!dbg.contains(secret));
307 // length must not leak either
308 assert!(!dbg.contains("23"));
309 }
310
311 #[test]
312 fn secret_string_clone_is_independent_allocation() {
313 let original = SecretString::new("independent".to_string());
314 let cloned = original.clone();
315 assert_eq!(original.expose_secret(), cloned.expose_secret());
316 // Distinct heap allocations: the backing String buffers differ.
317 assert_ne!(
318 original.expose_secret().as_ptr(),
319 cloned.expose_secret().as_ptr(),
320 "clone must own a separate allocation"
321 );
322 // Dropping the clone must not affect the original.
323 drop(cloned);
324 assert_eq!(original.expose_secret(), "independent");
325 }
326
327 // ---- SecretBytes ----
328
329 #[test]
330 fn secret_bytes_round_trip() {
331 let b = SecretBytes::new(vec![1, 2, 3, 4]);
332 assert_eq!(b.expose_secret(), &[1, 2, 3, 4]);
333 assert_eq!(b.len(), 4);
334 assert!(!b.is_empty());
335 assert!(SecretBytes::from(Vec::new()).is_empty());
336 assert_eq!(b.into_vec(), vec![1, 2, 3, 4]);
337 }
338
339 #[test]
340 fn secret_bytes_from() {
341 let b: SecretBytes = vec![9u8, 8, 7].into();
342 assert_eq!(b.expose_secret(), &[9, 8, 7]);
343 }
344
345 #[test]
346 fn secret_bytes_debug_redacted() {
347 let secret = [0xde, 0xad, 0xbe, 0xef];
348 let b = SecretBytes::new(secret.to_vec());
349 let dbg = format!("{b:?}");
350 assert_eq!(dbg, "SecretBytes(REDACTED)");
351 // No byte values leak.
352 assert!(!dbg.contains("222") && !dbg.contains("173"));
353 assert!(!dbg.contains("de") && !dbg.contains("ad"));
354 // Length must not leak.
355 assert!(!dbg.contains('4'));
356 }
357
358 #[test]
359 fn secret_bytes_clone_is_independent_allocation() {
360 let original = SecretBytes::new(vec![10, 20, 30]);
361 let cloned = original.clone();
362 assert_eq!(original.expose_secret(), cloned.expose_secret());
363 assert_ne!(
364 original.expose_secret().as_ptr(),
365 cloned.expose_secret().as_ptr(),
366 "clone must own a separate allocation"
367 );
368 drop(cloned);
369 assert_eq!(original.expose_secret(), &[10, 20, 30]);
370 }
371
372 // ---- SecretArray ----
373
374 #[test]
375 fn secret_array_round_trip() {
376 let key = [7u8; 32];
377 let a = SecretArray::<32>::new(key);
378 assert_eq!(a.expose_secret(), &key);
379 assert_eq!(a.len(), 32);
380 assert!(!a.is_empty());
381 assert_eq!(a.into_inner(), key);
382 }
383
384 #[test]
385 fn secret_array_from() {
386 let a: SecretArray<4> = [1u8, 2, 3, 4].into();
387 assert_eq!(a.expose_secret(), &[1, 2, 3, 4]);
388 }
389
390 #[test]
391 fn secret_array_empty() {
392 let a = SecretArray::<0>::new([]);
393 assert!(a.is_empty());
394 assert_eq!(a.len(), 0);
395 }
396
397 #[test]
398 fn secret_array_debug_redacted() {
399 let key = [0xABu8; 16];
400 let a = SecretArray::<16>::new(key);
401 let dbg = format!("{a:?}");
402 assert_eq!(dbg, "SecretArray(REDACTED)");
403 assert!(!dbg.contains("171")); // 0xAB
404 assert!(!dbg.contains("ab"));
405 assert!(!dbg.contains("16")); // length
406 }
407
408 #[test]
409 fn secret_array_clone_is_independent_value() {
410 let original = SecretArray::<32>::new([5u8; 32]);
411 let mut cloned = original.clone();
412 assert_eq!(original.expose_secret(), cloned.expose_secret());
413 // Mutating the clone's bytes (via a fresh value) must not touch the
414 // original; arrays are value types, so re-create and compare.
415 cloned = SecretArray::<32>::new([6u8; 32]);
416 assert_eq!(original.expose_secret(), &[5u8; 32]);
417 assert_eq!(cloned.expose_secret(), &[6u8; 32]);
418 }
419
420 /// Proves the zeroize actually overwrites the backing stack bytes.
421 ///
422 /// The workspace forbids `unsafe_code`, so the read-after-drop raw-pointer
423 /// pattern is unavailable. Instead we drive the same code path `Drop` runs
424 /// (`<Self as Zeroize>::zeroize`, which the derived `ZeroizeOnDrop` calls)
425 /// directly through a `&mut` borrow and then observe — safely — that every
426 /// backing byte is now zero. This is a real overwrite assertion on the
427 /// exact storage that `Drop` would clear.
428 #[test]
429 fn secret_array_zeroize_overwrites_backing_bytes() {
430 use zeroize::Zeroize;
431 let mut a = SecretArray::<32>::new([0xFFu8; 32]);
432 assert_eq!(a.expose_secret(), &[0xFFu8; 32]);
433 a.zeroize();
434 assert_eq!(
435 a.expose_secret(),
436 &[0u8; 32],
437 "zeroize must overwrite the stack bytes"
438 );
439 }
440
441 #[test]
442 fn secret_string_zeroize_overwrites_backing_bytes() {
443 use zeroize::Zeroize;
444 let mut s = SecretString::new("secret".to_string());
445 s.zeroize();
446 // zeroize on String clears the bytes and truncates to empty.
447 assert!(s.is_empty());
448 }
449
450 #[test]
451 fn secret_bytes_zeroize_overwrites_backing_bytes() {
452 use zeroize::Zeroize;
453 let mut b = SecretBytes::new(vec![0xFFu8; 16]);
454 b.zeroize();
455 // zeroize on Vec<u8> zeros the elements then clears length.
456 assert!(b.is_empty());
457 }
458}