Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-vault/src/secret.rs

//! The secret values a vault holds — and the types that keep them safe in RAM.
//!
//! ## The rules these types enforce (read this)
//!
//! * **Never plaintext to disk.** None of these types implement [`serde`]'s
//!   `Serialize`/`Deserialize`. The only way a secret becomes bytes is
//!   [`SecretEntry::encode`], which hands back a [`Zeroizing`] buffer that the
//!   vault feeds *straight into the AEAD* and then drops — the plaintext is never
//!   handed to a serializer that could reach a file or a log.
//! * **Never logged / never `Debug`-printed.** [`SecretString`]'s `Debug` prints
//!   `SecretString(***)`; [`SecretEntry`]'s prints only the variant name.
//! * **Zeroized on drop.** [`SecretString`] and the decoded [`SecretEntry`] wipe
//!   their bytes when they go out of scope (`zeroize`/`ZeroizeOnDrop`).
//! * **Only presence surfaces.** [`EntryKind`] is the *non-secret* label of an
//!   entry (`MtlsClientIdentity` / `SparkToken` / `Opaque`) — it, plus a
//!   present/absent boolean, is all that may appear in a UI `state_json`.

use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};

use crate::error::{Result, VaultError};

/// A UTF-8 secret (a PEM block, a token) that wipes itself on drop and refuses to
/// appear in logs. Clone is explicit (each clone is independently zeroized).
///
/// It intentionally does **not** implement `Serialize`/`Deserialize`, `Display`,
/// or a revealing `Debug` — the only reader is [`SecretString::expose`], named so
/// that every read site is greppable.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct SecretString(String);

impl SecretString {
    /// Wrap a secret string.
    pub fn new(s: impl Into<String>) -> Self {
        SecretString(s.into())
    }

    /// Borrow the plaintext. Named `expose` so every use is easy to audit; do not
    /// clone the returned `&str` into a long-lived `String`.
    pub fn expose(&self) -> &str {
        &self.0
    }
}

impl core::fmt::Debug for SecretString {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str("SecretString(***)")
    }
}

impl PartialEq for SecretString {
    /// Value equality (used by tests / de-dup). Not constant-time — these are
    /// compared in trusted, already-unlocked contexts, never as an auth check.
    fn eq(&self, other: &Self) -> bool {
        self.0 == other.0
    }
}
impl Eq for SecretString {}

/// The non-secret *kind* label of an entry. This — and only this, alongside a
/// present/absent boolean — is what may be surfaced to a UI (see
/// [`crate::VaultState`]). It carries no secret bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum EntryKind {
    /// A mutual-TLS client identity: a leaf certificate + its private key (PEM).
    MtlsClientIdentity,
    /// A Spark (Spark Connect) bearer token.
    SparkToken,
    /// Opaque bytes — the extension point (e.g. a serialized token bundle for the
    /// `nornir-auth-client` `TokenStore` bridge).
    Opaque,
}

/// One secret held by the vault. Extensible: add a variant + its [`EntryKind`].
///
/// Decoded copies live only in RAM inside an unlocked vault and are zeroized on
/// drop. This type has **no** `Serialize`/`Deserialize` — persistence goes
/// through [`SecretEntry::encode`]/[`SecretEntry::decode`] and the AEAD.
#[derive(Clone, PartialEq, Eq)]
pub enum SecretEntry {
    /// mTLS client cert + private key, both PEM. Used by a thin client to present
    /// a client certificate to FalkorDB / a gateway.
    MtlsClientIdentity {
        /// The client leaf certificate, PEM.
        cert_pem: SecretString,
        /// The matching private key, PEM.
        key_pem: SecretString,
    },
    /// A Spark Connect bearer token.
    SparkToken(SecretString),
    /// Arbitrary secret bytes (the extension point).
    Opaque(Zeroizing<Vec<u8>>),
}

impl core::fmt::Debug for SecretEntry {
    /// Redacted — prints the variant, never the secret.
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        let name = match self {
            SecretEntry::MtlsClientIdentity { .. } => "MtlsClientIdentity",
            SecretEntry::SparkToken(_) => "SparkToken",
            SecretEntry::Opaque(_) => "Opaque",
        };
        write!(f, "SecretEntry::{name}(***)")
    }
}

/// Private, borrowed wire form used *only* to serialise the mTLS pair into the
/// transient plaintext buffer. Borrows the exposed `&str`s so no owned copy of
/// the secret is made beyond the JSON bytes (which are `Zeroizing`).
#[derive(serde::Serialize)]
struct MtlsWireRef<'a> {
    cert: &'a str,
    key: &'a str,
}

/// Owned counterpart used only during [`SecretEntry::decode`].
#[derive(serde::Deserialize, Zeroize, ZeroizeOnDrop)]
struct MtlsWireOwned {
    cert: String,
    key: String,
}

impl SecretEntry {
    /// **Build an [`Opaque`](SecretEntry::Opaque) entry from plain bytes.**
    ///
    /// The variant is documented as *the extension point* — "a serialized token bundle
    /// for the `nornir-auth-client` `TokenStore` bridge" — and it was **unconstructable
    /// by the callers it exists for**: `Opaque` wraps a `Zeroizing<Vec<u8>>`, so naming
    /// it required a direct `zeroize` dependency in every consumer. korp had none
    /// (found while building exactly that bridge, 2026-09-04), and adding the crate to a
    /// consumer just to spell a constructor is the wrong way round: an extension point
    /// that leaks its wrapper type into the caller's manifest is not an extension point.
    ///
    /// The `Zeroizing` wrap is what this does, so the byte buffer is zeroized on drop
    /// exactly as an internally-built entry is.
    pub fn opaque(bytes: impl Into<Vec<u8>>) -> Self {
        SecretEntry::Opaque(Zeroizing::new(bytes.into()))
    }

    /// The non-secret kind of this entry.
    pub fn kind(&self) -> EntryKind {
        match self {
            SecretEntry::MtlsClientIdentity { .. } => EntryKind::MtlsClientIdentity,
            SecretEntry::SparkToken(_) => EntryKind::SparkToken,
            SecretEntry::Opaque(_) => EntryKind::Opaque,
        }
    }

    /// Serialise to a **transient, zeroizing** plaintext buffer. The vault seals
    /// this buffer with the DEK immediately and drops it; the plaintext never
    /// reaches a file or a log. This is the *only* way a secret becomes bytes.
    pub(crate) fn encode(&self) -> Zeroizing<Vec<u8>> {
        match self {
            SecretEntry::MtlsClientIdentity { cert_pem, key_pem } => {
                let wire = MtlsWireRef { cert: cert_pem.expose(), key: key_pem.expose() };
                // `to_vec` on a borrowed struct: the only new plaintext copy is
                // this Vec, which is Zeroizing.
                Zeroizing::new(serde_json::to_vec(&wire).unwrap_or_default())
            }
            SecretEntry::SparkToken(t) => Zeroizing::new(t.expose().as_bytes().to_vec()),
            SecretEntry::Opaque(b) => Zeroizing::new(b.to_vec()),
        }
    }

    /// Rebuild an entry from a decrypted plaintext buffer + its (non-secret) kind.
    pub(crate) fn decode(kind: EntryKind, bytes: &[u8]) -> Result<Self> {
        match kind {
            EntryKind::MtlsClientIdentity => {
                let w: MtlsWireOwned = serde_json::from_slice(bytes)?;
                Ok(SecretEntry::MtlsClientIdentity {
                    cert_pem: SecretString::new(w.cert.clone()),
                    key_pem: SecretString::new(w.key.clone()),
                })
                // `w` is ZeroizeOnDrop → its `String`s are wiped here.
            }
            EntryKind::SparkToken => {
                let s = String::from_utf8(bytes.to_vec())
                    .map_err(|_| VaultError::Provider("spark token is not utf-8".into()))?;
                Ok(SecretEntry::SparkToken(SecretString::new(s)))
            }
            EntryKind::Opaque => Ok(SecretEntry::Opaque(Zeroizing::new(bytes.to_vec()))),
        }
    }
}

#[cfg(test)]
mod opaque_ctor_tests {
    use super::*;

    /// **The extension point is usable without a `zeroize` dependency**, and what it
    /// builds is byte-identical to the internally-shaped entry.
    ///
    /// `Opaque` is documented as the slot for "a serialized token bundle for the
    /// `nornir-auth-client` `TokenStore` bridge", and a consumer building that bridge
    /// could not name the variant without pulling `zeroize` into its own manifest. An
    /// extension point that leaks its wrapper type into the caller's Cargo.toml is not
    /// one.
    #[test]
    fn opaque_can_be_built_from_plain_bytes_and_round_trips() {
        let e = SecretEntry::opaque(b"a-serialized-token-bundle".to_vec());
        assert_eq!(e.kind(), EntryKind::Opaque);
        // The bytes survive encode → decode, which is the path the AEAD wraps.
        let encoded = e.encode();
        let back = SecretEntry::decode(EntryKind::Opaque, &encoded).expect("decode");
        assert_eq!(back, e, "an opaque entry round-trips byte-for-byte");
        assert_eq!(&*encoded, b"a-serialized-token-bundle");
        // Both spellings agree — the constructor is the wrap and nothing else.
        assert_eq!(e, SecretEntry::Opaque(Zeroizing::new(b"a-serialized-token-bundle".to_vec())));
        // …and it takes a `&[u8]` too, so a caller need not clone into a Vec first.
        assert_eq!(SecretEntry::opaque(&b"xy"[..]).kind(), EntryKind::Opaque);
        // The Debug/Display surface must NOT carry the bytes: an opaque entry is a
        // secret, and the whole vault's contract is that secrets are never printed.
        let shown = format!("{:?}", e.kind());
        assert!(!shown.contains("token"), "the kind is non-secret: {shown}");
    }
}