Skip to main content

reliakit_secret/
lib.rs

1//! Secret-safe wrappers for values that should not leak through formatting or
2//! diagnostics.
3//!
4//! `reliakit-secret` provides [`Secret<T>`], a small wrapper that redacts its
5//! value in [`Debug`](core::fmt::Debug) and [`Display`](core::fmt::Display)
6//! output. Access to the wrapped value is explicit through [`ExposeSecret`].
7//!
8//! The crate does not claim memory zeroization, process isolation, or protection
9//! against memory inspection. Its purpose is to prevent accidental leaks through
10//! logs, error messages, debug output, and diagnostic reports.
11//!
12//! # Examples
13//!
14//! ```
15//! use reliakit_secret::{ExposeSecret, Secret};
16//!
17//! let token = Secret::new("ghp_example_token");
18//!
19//! assert_eq!(format!("{token:?}"), "Secret([REDACTED])");
20//! assert_eq!(format!("{token}"), "[REDACTED]");
21//! assert_eq!(token.expose_secret(), &"ghp_example_token");
22//! ```
23//!
24//! String-backed secrets are available when `alloc` is available:
25//!
26//! ```
27//! use reliakit_secret::{ExposeSecret, SecretString};
28//!
29//! let password = SecretString::from_string("correct horse battery staple");
30//! assert_eq!(password.expose_secret(), "correct horse battery staple");
31//! ```
32//!
33//! # Redacting a field inside a larger struct
34//!
35//! The common case is a secret that lives in a config or request struct. Because
36//! [`Secret<T>`] redacts itself, deriving `Debug` on the parent stays safe — the
37//! secret field renders as `[REDACTED]` while every other field prints normally,
38//! so the whole struct can be logged without leaking:
39//!
40//! ```
41//! use reliakit_secret::SecretString;
42//!
43//! #[derive(Debug)]
44//! struct DbConfig {
45//!     host: String,
46//!     port: u16,
47//!     password: SecretString,
48//! }
49//!
50//! let cfg = DbConfig {
51//!     host: "db.internal".into(),
52//!     port: 5432,
53//!     password: SecretString::from_string("hunter2"),
54//! };
55//!
56//! let rendered = format!("{cfg:?}");
57//! assert!(rendered.contains("db.internal"));
58//! assert!(rendered.contains("[REDACTED]"));
59//! assert!(!rendered.contains("hunter2")); // the secret never appears
60//! ```
61//!
62//! # Comparing secrets
63//!
64//! Checking a presented value against a stored secret with `==` on the exposed
65//! bytes can leak information through timing. Use [`Secret::ct_eq`], which
66//! compares in time that does not depend on how many leading bytes match:
67//!
68//! ```
69//! use reliakit_secret::SecretString;
70//!
71//! let stored = SecretString::from_string("s3cr3t-token");
72//! assert!(stored.ct_eq("s3cr3t-token"));
73//! assert!(!stored.ct_eq("s3cr3t-wrong"));
74//! ```
75//!
76//! # Feature flags
77//!
78//! - `std` is enabled by default.
79//! - `alloc` enables [`SecretString`] without `std`.
80//!
81//! # `no_std`
82//!
83//! The crate supports `no_std`. Use `default-features = false` for non-alloc
84//! generic secrets, or add `features = ["alloc"]` for [`SecretString`].
85
86#![cfg_attr(not(feature = "std"), no_std)]
87#![forbid(unsafe_code)]
88
89#[cfg(any(feature = "alloc", feature = "std"))]
90extern crate alloc;
91
92use core::fmt;
93
94#[cfg(any(feature = "alloc", feature = "std"))]
95use alloc::string::String;
96
97const REDACTED: &str = "[REDACTED]";
98
99/// Explicit access to a wrapped secret value.
100///
101/// This trait makes secret exposure visible at call sites:
102///
103/// ```
104/// use reliakit_secret::{ExposeSecret, Secret};
105///
106/// let secret = Secret::new("token");
107/// assert_eq!(secret.expose_secret(), &"token");
108/// ```
109pub trait ExposeSecret<T: ?Sized> {
110    /// Returns a shared reference to the wrapped secret value.
111    fn expose_secret(&self) -> &T;
112}
113
114/// Mutable access to a wrapped secret value.
115///
116/// Use this only when mutation is necessary. Prefer constructing a new
117/// [`Secret<T>`] when possible.
118pub trait ExposeSecretMut<T: ?Sized>: ExposeSecret<T> {
119    /// Returns a mutable reference to the wrapped secret value.
120    fn expose_secret_mut(&mut self) -> &mut T;
121}
122
123/// A value that redacts itself in formatting and diagnostics.
124///
125/// `Secret<T>` intentionally does not expose `T` through `Debug`, `Display`, or
126/// `AsRef`. Callers must use [`ExposeSecret::expose_secret`] or
127/// [`Secret::into_inner`] explicitly.
128pub struct Secret<T> {
129    inner: T,
130}
131
132impl<T> Secret<T> {
133    /// Wraps a value as a secret.
134    pub const fn new(inner: T) -> Self {
135        Self { inner }
136    }
137
138    /// Consumes the wrapper and returns the inner value.
139    pub fn into_inner(self) -> T {
140        self.inner
141    }
142
143    /// Maps a secret value into another secret value.
144    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Secret<U> {
145        Secret::new(f(self.inner))
146    }
147}
148
149impl<T: AsRef<[u8]>> Secret<T> {
150    /// Compares this secret's bytes to `other` in time that does not depend on
151    /// how many leading bytes match.
152    ///
153    /// Comparing a presented value against a stored secret with `==` returns as
154    /// soon as the first differing byte is found, which can leak the secret one
155    /// byte at a time through timing. `ct_eq` always inspects every byte, so the
156    /// duration reveals only the input length, not its contents. Inputs of
157    /// different lengths always compare unequal — the length itself is not
158    /// treated as secret.
159    ///
160    /// This is a best-effort, dependency-free implementation built on
161    /// [`core::hint::black_box`] to discourage the optimizer from
162    /// short-circuiting. If you require audited constant-time guarantees, use a
163    /// dedicated cryptographic comparison crate.
164    ///
165    /// Available for any secret whose value is byte-viewable (`String`,
166    /// `Vec<u8>`, `&str`, `&[u8]`, `[u8; N]`, ...). To compare two secrets, pass
167    /// the other's exposed value: `a.ct_eq(b.expose_secret())`.
168    ///
169    /// ```
170    /// use reliakit_secret::Secret;
171    ///
172    /// let stored = Secret::new(*b"abc123");
173    /// assert!(stored.ct_eq(b"abc123"));
174    /// assert!(!stored.ct_eq(b"abc124"));
175    /// assert!(!stored.ct_eq(b"abc")); // different length
176    /// ```
177    pub fn ct_eq(&self, other: impl AsRef<[u8]>) -> bool {
178        let a = self.inner.as_ref();
179        let b = other.as_ref();
180        if a.len() != b.len() {
181            return false;
182        }
183        let mut diff = 0u8;
184        for (x, y) in a.iter().zip(b.iter()) {
185            diff |= x ^ y;
186        }
187        core::hint::black_box(diff) == 0
188    }
189}
190
191impl<T> ExposeSecret<T> for Secret<T> {
192    fn expose_secret(&self) -> &T {
193        &self.inner
194    }
195}
196
197impl<T> ExposeSecretMut<T> for Secret<T> {
198    fn expose_secret_mut(&mut self) -> &mut T {
199        &mut self.inner
200    }
201}
202
203impl<T: Clone> Clone for Secret<T> {
204    fn clone(&self) -> Self {
205        Self::new(self.inner.clone())
206    }
207}
208
209impl<T: Default> Default for Secret<T> {
210    fn default() -> Self {
211        Self::new(T::default())
212    }
213}
214
215impl<T> fmt::Debug for Secret<T> {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        f.write_str("Secret(")?;
218        f.write_str(REDACTED)?;
219        f.write_str(")")
220    }
221}
222
223impl<T> fmt::Display for Secret<T> {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        f.write_str(REDACTED)
226    }
227}
228
229impl<T> From<T> for Secret<T> {
230    fn from(value: T) -> Self {
231        Self::new(value)
232    }
233}
234
235/// String-backed secret value.
236#[cfg(any(feature = "alloc", feature = "std"))]
237pub type SecretString = Secret<String>;
238
239#[cfg(any(feature = "alloc", feature = "std"))]
240impl SecretString {
241    /// Creates a string-backed secret from any string-like value.
242    pub fn from_string(value: impl Into<String>) -> Self {
243        Self::new(value.into())
244    }
245
246    /// Returns the secret as `str`.
247    pub fn expose_str(&self) -> &str {
248        self.inner.as_str()
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::{ExposeSecret, ExposeSecretMut, Secret, SecretString};
255    use alloc::format;
256    use alloc::string::ToString;
257
258    #[test]
259    fn debug_redacts_secret() {
260        let secret = Secret::new("token-123");
261        assert_eq!(format!("{secret:?}"), "Secret([REDACTED])");
262    }
263
264    #[test]
265    fn display_redacts_secret() {
266        let secret = Secret::new("token-123");
267        assert_eq!(secret.to_string(), "[REDACTED]");
268    }
269
270    #[test]
271    fn expose_secret_returns_inner_reference() {
272        let secret = Secret::new("token-123");
273        assert_eq!(secret.expose_secret(), &"token-123");
274    }
275
276    #[test]
277    fn expose_secret_mut_allows_explicit_mutation() {
278        let mut secret = Secret::new(1_u8);
279        *secret.expose_secret_mut() = 2;
280        assert_eq!(secret.expose_secret(), &2);
281    }
282
283    #[test]
284    fn into_inner_returns_inner_value() {
285        let secret = Secret::new("token-123");
286        assert_eq!(secret.into_inner(), "token-123");
287    }
288
289    #[test]
290    fn map_returns_new_secret() {
291        let secret = Secret::new("token").map(|value| value.len());
292        assert_eq!(secret.expose_secret(), &5);
293        assert_eq!(format!("{secret:?}"), "Secret([REDACTED])");
294    }
295
296    #[test]
297    fn clone_clones_inner_value_without_leaking_debug() {
298        let secret = Secret::new("token".to_string());
299        let cloned = secret.clone();
300        assert_eq!(cloned.expose_secret(), "token");
301        assert_eq!(format!("{cloned:?}"), "Secret([REDACTED])");
302    }
303
304    #[test]
305    fn secret_string_wraps_owned_string() {
306        let secret = SecretString::from_string("password");
307        assert_eq!(secret.expose_secret(), "password");
308        assert_eq!(secret.expose_str(), "password");
309        assert_eq!(secret.to_string(), "[REDACTED]");
310    }
311
312    #[test]
313    fn ct_eq_matches_equal_bytes() {
314        let secret = SecretString::from_string("s3cr3t-token");
315        assert!(secret.ct_eq("s3cr3t-token"));
316        assert!(secret.ct_eq(b"s3cr3t-token"));
317    }
318
319    #[test]
320    fn ct_eq_rejects_different_content_same_length() {
321        let secret = SecretString::from_string("s3cr3t-token");
322        assert!(!secret.ct_eq("s3cr3t-tokeX"));
323        // First and last bytes differ but length matches.
324        assert!(!secret.ct_eq("X3cr3t-tokeX"));
325    }
326
327    #[test]
328    fn ct_eq_rejects_different_length() {
329        let secret = SecretString::from_string("abc");
330        assert!(!secret.ct_eq("ab"));
331        assert!(!secret.ct_eq("abcd"));
332        assert!(!secret.ct_eq(""));
333    }
334
335    #[test]
336    fn ct_eq_works_for_byte_arrays_without_alloc_types() {
337        let secret = Secret::new(*b"key-bytes");
338        assert!(secret.ct_eq(b"key-bytes"));
339        assert!(!secret.ct_eq(b"key-byteX"));
340    }
341
342    #[test]
343    fn ct_eq_empty_secret_matches_empty() {
344        let secret = SecretString::from_string("");
345        assert!(secret.ct_eq(""));
346        assert!(!secret.ct_eq("x"));
347    }
348
349    #[test]
350    fn ct_eq_between_two_secrets_via_expose() {
351        let a = SecretString::from_string("same-value");
352        let b = SecretString::from_string("same-value");
353        assert!(a.ct_eq(b.expose_secret()));
354    }
355}