polyc-crypto 2026.7.1

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;

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 an LLM provider client, which then
    /// holds its own untracked, un-zeroized copy for as long as that client
    /// lives. Closing that gap needs `polyc-llm`'s provider trait to accept
    /// `Sensitive` directly rather than a plain `String`; tracked as a
    /// follow-up, not yet done.
    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<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();
    }
}

#[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");
    }
}