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}