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}