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