Skip to main content

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}