polyc-crypto 2026.8.0

Provenance signatures (commonware-cryptography ed25519) for polychrome tool calls.
Documentation
//! [`Sensitive<T>`]: a secret-value wrapper that redacts `Debug`/`Display`,
//! never serializes, and zeroizes its contents on drop (#1169).
//!
//! Config structs across the workspace hold credential/key fields — an LLM
//! provider's bearer key, a wallet signer key, an HMAC challenge secret — as
//! plain `String`s today. A plain `String` field prints its raw value from a
//! derived `Debug` impl, from any accidental `{}`/`{:?}` in a log line, and
//! from a derived `Serialize` impl the moment the enclosing struct is ever
//! serialized (a debug endpoint, a forensics dump, a stray `serde_json::to_string`).
//! [`Sensitive<T>`] closes all three holes at the type level: it only ever
//! prints `Sensitive(<redacted>)`, it deliberately has no `Serialize` impl (so
//! a struct that embeds one fails to compile if something tries to derive
//! `Serialize` over it, rather than silently leaking), and its value is
//! wiped from memory as soon as it drops.
//!
//! Reads the secret back out only through the explicit
//! [`expose`](Sensitive::expose) / [`expose_secret`](Sensitive::expose_secret)
//! accessors (identical; `expose_secret` matches the naming the `secrecy`
//! crate uses, so call sites read the same regardless of which wrapper backs
//! them) — every use site is grep-able and visibly intentional.
//!
//! ## Why a local newtype instead of `secrecy::SecretString`
//!
//! The `secrecy` crate (already resolved transitively in this workspace, via
//! `kube-client`) redacts `Debug` and zeroizes on drop, but deliberately does
//! **not** implement `Display` — printing a secret via `{}` is exactly the
//! footgun it exists to prevent, so it forces every read through
//! `expose_secret()`. Issue #1169 asks for a redacted `Display` too (so a
//! stray `format!("{secret}")` — not just `{:?}` — still can't leak), and for
//! the wrapper to print as `Sensitive(<redacted>)` specifically. Bridging
//! that gap by wrapping `SecretString` in another newtype would add a layer
//! of indirection with no upside over implementing the same
//! zeroize-on-drop + redacted-formatting contract directly against the
//! `zeroize` crate, which this workspace already depends on
//! (`crates/passkey`). A thin local type also stays generic over any `T:
//! Zeroize` (not just `String`), so it can wrap a future non-`String` secret
//! (e.g. raw key bytes) without another wrapper.

use std::{fmt, str::FromStr};

use zeroize::Zeroize;

/// A secret value, redacted in `Debug`/`Display` and zeroized on drop.
///
/// Never derive or implement `Serialize` on a type embedding this — that is
/// the point: [`Sensitive`] deliberately has no `Serialize` impl, so a
/// container that tries to derive one over a field of this type fails to
/// compile instead of silently emitting the raw secret.
///
/// Deserializing (reading a secret in from TOML/env/CLI) is fine and
/// supported via `serde`'s `Deserialize` — only the write-out direction is
/// closed.
#[derive(Clone, serde::Deserialize)]
#[serde(transparent)]
pub struct Sensitive<T: Zeroize>(T);

impl<T: Zeroize> Sensitive<T> {
    /// Wraps `value`; ordinary `Debug`/`Display` no longer print it.
    pub const fn new(value: T) -> Self {
        Self(value)
    }

    /// Returns the wrapped value. The explicit name makes every read site
    /// grep-able (`rg '\.expose\('`) and visibly intentional.
    ///
    /// The borrow this returns stays covered by [`Sensitive`]'s redaction and
    /// zeroize-on-drop, but nothing stops a call site from cloning it out —
    /// e.g. handing an owned `String` to a client that then holds its own
    /// untracked, un-zeroized copy for as long as that client lives. The LLM
    /// provider configs and constructors hold the key wrapped for their whole
    /// lifetime instead (`#1277`); see the provider crates. The general risk
    /// remains for any other call site that reaches for `expose` and clones
    /// the result into a plain, unwrapped copy.
    pub const fn expose(&self) -> &T {
        &self.0
    }

    /// Alias for [`Self::expose`], matching the `secrecy` crate's accessor
    /// name for call sites migrating between the two wrappers. Same
    /// past-this-point caveat: see [`Self::expose`].
    pub const fn expose_secret(&self) -> &T {
        &self.0
    }
}

impl Sensitive<String> {
    /// Treats an empty string as "not configured".
    ///
    /// Config loading routinely needs to turn an optional secret field into
    /// `None` when it's merely present-but-empty — the wire encoding a
    /// shipped manifest's `ConfigMap` uses for "unset" — before ever making a
    /// request with it. That check has to read the wrapped value, so this is
    /// the one sanctioned emptiness peek on a [`Sensitive<String>`] outside a
    /// request path; every other read site should reach for [`Self::expose`]
    /// only at the point where the secret is actually used (e.g. an auth
    /// header), not to inspect or branch on it ahead of time.
    ///
    /// Returns `None` for `None` or `Some` wrapping `""`, otherwise clones
    /// `opt`'s value into a fresh, independently-owned `Some`.
    #[must_use]
    pub fn filter_nonempty(opt: Option<&Self>) -> Option<Self> {
        opt.filter(|s| !s.expose().is_empty()).cloned()
    }
}

impl<T: Zeroize> From<T> for Sensitive<T> {
    fn from(value: T) -> Self {
        Self::new(value)
    }
}

impl<T: Zeroize> fmt::Debug for Sensitive<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Sensitive(<redacted>)")
    }
}

impl<T: Zeroize> fmt::Display for Sensitive<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Sensitive(<redacted>)")
    }
}

impl<T: Zeroize> Drop for Sensitive<T> {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

impl FromStr for Sensitive<String> {
    type Err = std::convert::Infallible;

    /// Wraps the raw string so a clap `Args`/`Parser` field declared
    /// `Sensitive<String>` parses straight off the CLI/env value — clap infers
    /// a value parser from `FromStr` for any type that isn't a `ValueEnum`.
    ///
    /// Deliberately concrete on `String` rather than a blanket
    /// `impl<T: FromStr>`: a blanket impl registers `Sensitive<_>` as a
    /// `FromStr` candidate for every open inference variable in every
    /// downstream crate, which silently reshapes unrelated `.parse()` inference
    /// (it drove `polyc-agent`'s env parsing to a `LazyLock<String>` fallback
    /// that no longer type-checked). Every edge wraps a `String`, so a concrete
    /// impl carries the full feature with none of the inference blast radius.
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Self::new(s.to_string()))
    }
}

#[cfg(test)]
mod tests {
    use super::Sensitive;

    /// Invariant: neither `Debug` nor `Display` ever print the wrapped value,
    /// only the fixed redaction marker.
    #[test]
    fn debug_and_display_redact() {
        let secret = Sensitive::new("super-secret-token".to_string());
        let debug = format!("{secret:?}");
        let display = format!("{secret}");
        assert_eq!(debug, "Sensitive(<redacted>)");
        assert_eq!(display, "Sensitive(<redacted>)");
        assert!(!debug.contains("super-secret-token"));
        assert!(!display.contains("super-secret-token"));
    }

    /// Invariant: `expose`/`expose_secret` both return the original value.
    #[test]
    fn expose_returns_the_value() {
        let secret = Sensitive::new("super-secret-token".to_string());
        assert_eq!(secret.expose(), "super-secret-token");
        assert_eq!(secret.expose_secret(), "super-secret-token");
    }

    /// Invariant: `FromStr` round-trips through `T::from_str` — parsing a
    /// `Sensitive<String>` from a raw string yields a value that exposes back
    /// to that exact string.
    #[test]
    fn from_str_delegates_to_inner() {
        let secret: Sensitive<String> = "cli-parsed-secret".parse().expect("infallible");
        assert_eq!(secret.expose(), "cli-parsed-secret");
    }

    /// Invariant: an empty wrapped string is treated as "not configured".
    #[test]
    fn filter_nonempty_treats_empty_as_none() {
        let empty = Sensitive::new(String::new());
        assert!(Sensitive::filter_nonempty(Some(&empty)).is_none());
    }

    /// Invariant: a non-empty wrapped string round-trips to `Some` with the
    /// same value.
    #[test]
    fn filter_nonempty_keeps_non_empty() {
        let key = Sensitive::new("sk-live-key".to_string());
        let filtered = Sensitive::filter_nonempty(Some(&key)).expect("non-empty stays Some");
        assert_eq!(filtered.expose(), "sk-live-key");
    }

    /// Invariant: no value at all stays `None`.
    #[test]
    fn filter_nonempty_none_stays_none() {
        assert!(Sensitive::filter_nonempty(None).is_none());
    }
}