Skip to main content

polyc_crypto/
sensitive.rs

1//! [`Sensitive<T>`]: a secret-value wrapper that redacts `Debug`/`Display`,
2//! never serializes, and zeroizes its contents on drop (#1169).
3//!
4//! Config structs across the workspace hold credential/key fields — an LLM
5//! provider's bearer key, a wallet signer key, an HMAC challenge secret — as
6//! plain `String`s today. A plain `String` field prints its raw value from a
7//! derived `Debug` impl, from any accidental `{}`/`{:?}` in a log line, and
8//! from a derived `Serialize` impl the moment the enclosing struct is ever
9//! serialized (a debug endpoint, a forensics dump, a stray `serde_json::to_string`).
10//! [`Sensitive<T>`] closes all three holes at the type level: it only ever
11//! prints `Sensitive(<redacted>)`, it deliberately has no `Serialize` impl (so
12//! a struct that embeds one fails to compile if something tries to derive
13//! `Serialize` over it, rather than silently leaking), and its value is
14//! wiped from memory as soon as it drops.
15//!
16//! Reads the secret back out only through the explicit
17//! [`expose`](Sensitive::expose) / [`expose_secret`](Sensitive::expose_secret)
18//! accessors (identical; `expose_secret` matches the naming the `secrecy`
19//! crate uses, so call sites read the same regardless of which wrapper backs
20//! them) — every use site is grep-able and visibly intentional.
21//!
22//! ## Why a local newtype instead of `secrecy::SecretString`
23//!
24//! The `secrecy` crate (already resolved transitively in this workspace, via
25//! `kube-client`) redacts `Debug` and zeroizes on drop, but deliberately does
26//! **not** implement `Display` — printing a secret via `{}` is exactly the
27//! footgun it exists to prevent, so it forces every read through
28//! `expose_secret()`. Issue #1169 asks for a redacted `Display` too (so a
29//! stray `format!("{secret}")` — not just `{:?}` — still can't leak), and for
30//! the wrapper to print as `Sensitive(<redacted>)` specifically. Bridging
31//! that gap by wrapping `SecretString` in another newtype would add a layer
32//! of indirection with no upside over implementing the same
33//! zeroize-on-drop + redacted-formatting contract directly against the
34//! `zeroize` crate, which this workspace already depends on
35//! (`crates/passkey`). A thin local type also stays generic over any `T:
36//! Zeroize` (not just `String`), so it can wrap a future non-`String` secret
37//! (e.g. raw key bytes) without another wrapper.
38
39use std::fmt;
40
41use zeroize::Zeroize;
42
43/// A secret value, redacted in `Debug`/`Display` and zeroized on drop.
44///
45/// Never derive or implement `Serialize` on a type embedding this — that is
46/// the point: [`Sensitive`] deliberately has no `Serialize` impl, so a
47/// container that tries to derive one over a field of this type fails to
48/// compile instead of silently emitting the raw secret.
49///
50/// Deserializing (reading a secret in from TOML/env/CLI) is fine and
51/// supported via `serde`'s `Deserialize` — only the write-out direction is
52/// closed.
53#[derive(Clone, serde::Deserialize)]
54#[serde(transparent)]
55pub struct Sensitive<T: Zeroize>(T);
56
57impl<T: Zeroize> Sensitive<T> {
58    /// Wraps `value`; ordinary `Debug`/`Display` no longer print it.
59    pub const fn new(value: T) -> Self {
60        Self(value)
61    }
62
63    /// Returns the wrapped value. The explicit name makes every read site
64    /// grep-able (`rg '\.expose\('`) and visibly intentional.
65    ///
66    /// The borrow this returns stays covered by [`Sensitive`]'s redaction and
67    /// zeroize-on-drop, but nothing stops a call site from cloning it out —
68    /// e.g. handing an owned `String` to an LLM provider client, which then
69    /// holds its own untracked, un-zeroized copy for as long as that client
70    /// lives. Closing that gap needs `polyc-llm`'s provider trait to accept
71    /// `Sensitive` directly rather than a plain `String`; tracked as a
72    /// follow-up, not yet done.
73    pub const fn expose(&self) -> &T {
74        &self.0
75    }
76
77    /// Alias for [`Self::expose`], matching the `secrecy` crate's accessor
78    /// name for call sites migrating between the two wrappers. Same
79    /// past-this-point caveat: see [`Self::expose`].
80    pub const fn expose_secret(&self) -> &T {
81        &self.0
82    }
83}
84
85impl<T: Zeroize> From<T> for Sensitive<T> {
86    fn from(value: T) -> Self {
87        Self::new(value)
88    }
89}
90
91impl<T: Zeroize> fmt::Debug for Sensitive<T> {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str("Sensitive(<redacted>)")
94    }
95}
96
97impl<T: Zeroize> fmt::Display for Sensitive<T> {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str("Sensitive(<redacted>)")
100    }
101}
102
103impl<T: Zeroize> Drop for Sensitive<T> {
104    fn drop(&mut self) {
105        self.0.zeroize();
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::Sensitive;
112
113    /// Invariant: neither `Debug` nor `Display` ever print the wrapped value,
114    /// only the fixed redaction marker.
115    #[test]
116    fn debug_and_display_redact() {
117        let secret = Sensitive::new("super-secret-token".to_string());
118        let debug = format!("{secret:?}");
119        let display = format!("{secret}");
120        assert_eq!(debug, "Sensitive(<redacted>)");
121        assert_eq!(display, "Sensitive(<redacted>)");
122        assert!(!debug.contains("super-secret-token"));
123        assert!(!display.contains("super-secret-token"));
124    }
125
126    /// Invariant: `expose`/`expose_secret` both return the original value.
127    #[test]
128    fn expose_returns_the_value() {
129        let secret = Sensitive::new("super-secret-token".to_string());
130        assert_eq!(secret.expose(), "super-secret-token");
131        assert_eq!(secret.expose_secret(), "super-secret-token");
132    }
133}