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, str::FromStr};
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 a client that then holds its own
69    /// untracked, un-zeroized copy for as long as that client lives. The LLM
70    /// provider configs and constructors hold the key wrapped for their whole
71    /// lifetime instead (`#1277`); see the provider crates. The general risk
72    /// remains for any other call site that reaches for `expose` and clones
73    /// the result into a plain, unwrapped copy.
74    pub const fn expose(&self) -> &T {
75        &self.0
76    }
77
78    /// Alias for [`Self::expose`], matching the `secrecy` crate's accessor
79    /// name for call sites migrating between the two wrappers. Same
80    /// past-this-point caveat: see [`Self::expose`].
81    pub const fn expose_secret(&self) -> &T {
82        &self.0
83    }
84}
85
86impl Sensitive<String> {
87    /// Treats an empty string as "not configured".
88    ///
89    /// Config loading routinely needs to turn an optional secret field into
90    /// `None` when it's merely present-but-empty — the wire encoding a
91    /// shipped manifest's `ConfigMap` uses for "unset" — before ever making a
92    /// request with it. That check has to read the wrapped value, so this is
93    /// the one sanctioned emptiness peek on a [`Sensitive<String>`] outside a
94    /// request path; every other read site should reach for [`Self::expose`]
95    /// only at the point where the secret is actually used (e.g. an auth
96    /// header), not to inspect or branch on it ahead of time.
97    ///
98    /// Returns `None` for `None` or `Some` wrapping `""`, otherwise clones
99    /// `opt`'s value into a fresh, independently-owned `Some`.
100    #[must_use]
101    pub fn filter_nonempty(opt: Option<&Self>) -> Option<Self> {
102        opt.filter(|s| !s.expose().is_empty()).cloned()
103    }
104}
105
106impl<T: Zeroize> From<T> for Sensitive<T> {
107    fn from(value: T) -> Self {
108        Self::new(value)
109    }
110}
111
112impl<T: Zeroize> fmt::Debug for Sensitive<T> {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str("Sensitive(<redacted>)")
115    }
116}
117
118impl<T: Zeroize> fmt::Display for Sensitive<T> {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str("Sensitive(<redacted>)")
121    }
122}
123
124impl<T: Zeroize> Drop for Sensitive<T> {
125    fn drop(&mut self) {
126        self.0.zeroize();
127    }
128}
129
130impl FromStr for Sensitive<String> {
131    type Err = std::convert::Infallible;
132
133    /// Wraps the raw string so a clap `Args`/`Parser` field declared
134    /// `Sensitive<String>` parses straight off the CLI/env value — clap infers
135    /// a value parser from `FromStr` for any type that isn't a `ValueEnum`.
136    ///
137    /// Deliberately concrete on `String` rather than a blanket
138    /// `impl<T: FromStr>`: a blanket impl registers `Sensitive<_>` as a
139    /// `FromStr` candidate for every open inference variable in every
140    /// downstream crate, which silently reshapes unrelated `.parse()` inference
141    /// (it drove `polyc-agent`'s env parsing to a `LazyLock<String>` fallback
142    /// that no longer type-checked). Every edge wraps a `String`, so a concrete
143    /// impl carries the full feature with none of the inference blast radius.
144    fn from_str(s: &str) -> Result<Self, Self::Err> {
145        Ok(Self::new(s.to_string()))
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::Sensitive;
152
153    /// Invariant: neither `Debug` nor `Display` ever print the wrapped value,
154    /// only the fixed redaction marker.
155    #[test]
156    fn debug_and_display_redact() {
157        let secret = Sensitive::new("super-secret-token".to_string());
158        let debug = format!("{secret:?}");
159        let display = format!("{secret}");
160        assert_eq!(debug, "Sensitive(<redacted>)");
161        assert_eq!(display, "Sensitive(<redacted>)");
162        assert!(!debug.contains("super-secret-token"));
163        assert!(!display.contains("super-secret-token"));
164    }
165
166    /// Invariant: `expose`/`expose_secret` both return the original value.
167    #[test]
168    fn expose_returns_the_value() {
169        let secret = Sensitive::new("super-secret-token".to_string());
170        assert_eq!(secret.expose(), "super-secret-token");
171        assert_eq!(secret.expose_secret(), "super-secret-token");
172    }
173
174    /// Invariant: `FromStr` round-trips through `T::from_str` — parsing a
175    /// `Sensitive<String>` from a raw string yields a value that exposes back
176    /// to that exact string.
177    #[test]
178    fn from_str_delegates_to_inner() {
179        let secret: Sensitive<String> = "cli-parsed-secret".parse().expect("infallible");
180        assert_eq!(secret.expose(), "cli-parsed-secret");
181    }
182
183    /// Invariant: an empty wrapped string is treated as "not configured".
184    #[test]
185    fn filter_nonempty_treats_empty_as_none() {
186        let empty = Sensitive::new(String::new());
187        assert!(Sensitive::filter_nonempty(Some(&empty)).is_none());
188    }
189
190    /// Invariant: a non-empty wrapped string round-trips to `Some` with the
191    /// same value.
192    #[test]
193    fn filter_nonempty_keeps_non_empty() {
194        let key = Sensitive::new("sk-live-key".to_string());
195        let filtered = Sensitive::filter_nonempty(Some(&key)).expect("non-empty stays Some");
196        assert_eq!(filtered.expose(), "sk-live-key");
197    }
198
199    /// Invariant: no value at all stays `None`.
200    #[test]
201    fn filter_nonempty_none_stays_none() {
202        assert!(Sensitive::filter_nonempty(None).is_none());
203    }
204}