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}