cindy 0.1.1

Managing infrastructure at breakneck speed.
Documentation
//! Vaulted secrets. A `Secret<T>` is a wrapper that:
//!
//!   * Round-trips through serde — JSON, postcard, anything — as a
//!     compact tagged blob (`{ vault, ciphertext }`).
//!   * Reveals the inner `T` only on an explicit `.reveal()` call,
//!     using a per-vault data-encryption key looked up at runtime.
//!   * Refuses to leak the plaintext via `Debug`/`Display`. Drops with
//!     `zeroize` so plaintext doesn't linger in freed memory.
//!
//! There are two states the type lives in over its lifecycle. **Plain**
//! is only meant to exist transiently in source code that hasn't been
//! sealed yet (and at runtime in dev/tests); the `cindy secret seal`
//! workflow rewrites these into the **Sealed** form. The sealed form
//! is what the CLI actually serialises, deserialises, ships through
//! `CINDY_HOST_CONTEXT`, and so on.

use std::fmt;
use std::marker::PhantomData;

use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde::{Deserializer, Serializer};

pub mod crypto;
pub mod keychain;

use crate::Context as _;

/// Vaulted wrapper over `T`.
///
/// Two states:
///   * `Plain(T)` — pre-seal. The CLI's `cindy secret seal` step rewrites
///     this in source to `Sealed { .. }`. At runtime, `.reveal()` just
///     hands the inner value back.
///   * `Sealed { vault, ciphertext }` — committed form. `.reveal()`
///     looks up the named vault's DEK via the keychain and decrypts.
pub enum Secret<T> {
    Plain(T),
    Sealed { vault: String, ciphertext: Vec<u8> },
}

/// What actually gets encrypted by `cindy secret seal`. The wrapper
/// carries both the postcard-encoded `T` (so `Secret::<T>::reveal`
/// can hand a typed value back to user code) *and* the literal
/// source-tokens of the original `secret!` value expression (so
/// `cindy secret unseal` can splice the same syntax back into the file
/// and recover the editable shape).
///
/// Cost: roughly 2× the ciphertext size compared with a postcard-only
/// payload. Worth it for the round-trip UX — and `cargo fmt` cleans up
/// the whitespace `stringify!()` injects.
#[doc(hidden)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SealedPayload {
    /// `postcard::to_allocvec(&value)` — the bytes `Secret<T>::reveal`
    /// hands to `postcard::from_bytes::<T>(...)`.
    pub value: Vec<u8>,
    /// `stringify!($value)` captured at macro time. Used only by
    /// `cindy secret unseal` to put the original `secret!(...)` call
    /// back. Encrypted alongside `value` so leaking nothing about
    /// shape/field-names.
    pub source: String,
}

impl<T> Secret<T> {
    /// Construct a not-yet-sealed secret. Used in source code before
    /// `cindy secret seal` rewrites it. Never produced at runtime on the
    /// orchestrator.
    pub fn new(value: T) -> Self {
        Self::Plain(value)
    }

    /// Construct an already-sealed secret. Used by the seal step (and
    /// by serde deserialisation).
    pub fn sealed(vault: impl Into<String>, ciphertext: Vec<u8>) -> Self {
        Self::Sealed {
            vault: vault.into(),
            ciphertext,
        }
    }

    /// Construct a sealed secret from a base64-encoded ciphertext.
    /// This is the constructor the `cindy secret seal` source-rewriter
    /// emits in your code, because pasting raw bytes into a `.rs` file
    /// is awful and base64 is unambiguous + greppable.
    ///
    /// Panics at runtime if `ciphertext_b64` isn't valid base64; we'd
    /// rather fail loudly than silently produce a `Sealed` with bogus
    /// bytes that only blows up much later in `.reveal()`.
    pub fn sealed_b64(vault: impl Into<String>, ciphertext_b64: &str) -> Self {
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(ciphertext_b64.as_bytes())
            .expect("`Secret::sealed_b64` got non-base64 ciphertext");
        Self::Sealed {
            vault: vault.into(),
            ciphertext: bytes,
        }
    }

    /// Name of the vault this secret is encrypted under. `Plain`
    /// secrets have no vault yet; this returns `None` for them.
    pub fn vault(&self) -> Option<&str> {
        match self {
            Self::Plain(_) => None,
            Self::Sealed { vault, .. } => Some(vault.as_str()),
        }
    }
}

/// Inventory-crate slot for a `secret!(...)` macro invocation waiting
/// to be sealed. The `#[cindy::main]`-generated entry walks all
/// `PendingSecret`s when launched with `CINDY_SEAL_SECRETS=1`, invokes
/// each `serialize` thunk to get the plaintext bytes, encrypts them
/// under the named vault's DEK, and emits one JSON object per pending
/// secret on stdout so the CLI can patch the source files.
#[doc(hidden)]
pub struct PendingSecret {
    /// Source-file path captured by `file!()` at the macro call site.
    pub file: &'static str,
    /// 1-based line number from `line!()`.
    pub line: u32,
    /// 1-based column number from `column!()`.
    pub column: u32,
    /// Vault name (the macro's first argument).
    pub vault: &'static str,
    /// Thunk that re-evaluates the macro's value expression and
    /// returns its `postcard`-serialised bytes. Required to be a
    /// `fn` pointer (no captures) so it's registerable at link time;
    /// this is what forces the "literals-only" constraint on
    /// `secret!`.
    pub serialize: fn() -> Vec<u8>,
}

crate::__reexports::inventory::collect!(PendingSecret);

/// Wrap a literal value in a [`Secret<T>`] and register it for the
/// `cindy secret seal` step to encrypt in-place.
///
/// At runtime this expands to `cindy::Secret::Plain(value)` so your code
/// works unchanged during development and tests. At seal time the CLI
/// invokes the orchestrator with `CINDY_SEAL_SECRETS=1`, which walks
/// every `PendingSecret` registered by this macro, encrypts each
/// thunk's serialised plaintext under the named vault, and emits a
/// list of (file, line, column, vault, ciphertext) tuples. The CLI
/// then rewrites every `secret!(...)` call site in source to
/// `Secret::sealed_b64(...)`, removing the plaintext from the file.
///
/// **Constraint**: the value expression must be self-contained — no
/// references to local variables in the surrounding scope. The macro
/// expands `expr` into a `fn() -> Vec<u8>` thunk so it can be
/// registered at link time, and `fn` pointers can't capture. Literal
/// struct values (`VyosCreds { user: "admin".into(), pw: "h2".into() }`)
/// work; values built with `format!`, locals, or other captures don't.
///
/// `include_str!` / `include_bytes!` are evaluated *before* this macro
/// sees them so they're fine — the literal `&'static str` / `&'static
/// [u8]` they expand to is itself a no-capture literal.
///
/// ```ignore
/// let pw = cindy::secret!("prod", VyosCreds {
///     user: "admin".into(),
///     pw:   "hunter2".into(),
/// });
/// // After `cindy secret seal`:
/// // let pw = ::cindy::Secret::sealed_b64("prod", "AAAA...");
/// ```
#[macro_export]
macro_rules! secret {
    ($vault:literal, $value:expr $(,)?) => {{
        fn __cindy_secret_serialize() -> ::std::vec::Vec<u8> {
            let value_bytes = $crate::__reexports::postcard::to_allocvec(&($value))
                .expect("`cindy::secret!`: postcard couldn't serialise the value");
            let payload = $crate::secret::SealedPayload {
                value: value_bytes,
                source: ::core::stringify!($value).to_owned(),
            };
            $crate::__reexports::postcard::to_allocvec(&payload)
                .expect("`cindy::secret!`: postcard couldn't serialise the SealedPayload")
        }
        $crate::__reexports::inventory::submit! {
            $crate::secret::PendingSecret {
                file:      ::core::file!(),
                line:      ::core::line!(),
                column:    ::core::column!(),
                vault:     $vault,
                serialize: __cindy_secret_serialize,
            }
        }
        $crate::Secret::new($value)
    }};
}

impl<T: serde::de::DeserializeOwned> Secret<T> {
    /// Reveal the inner value. For `Plain`, returns it as-is. For
    /// `Sealed`, fetches the vault DEK from the keychain, decrypts
    /// the ciphertext into a [`SealedPayload`], and
    /// `postcard`-deserialises the inner `T` out of its `value`
    /// field. The `source` field of the payload is ignored here —
    /// it's there for `cindy secret unseal`.
    pub fn reveal(self) -> crate::Result<T> {
        match self {
            Self::Plain(v) => Ok(v),
            Self::Sealed { vault, ciphertext } => {
                let dek = keychain::get_dek(&vault)?;
                let plaintext = crypto::unseal(&dek, &ciphertext)?;
                let payload: SealedPayload =
                    postcard::from_bytes(&plaintext).context(format!(
                            "couldn't deserialise SealedPayload for vault `{vault}`. \
                             If this secret was sealed by an older cindy version, \
                             rewrite it as `cindy::secret!(\"{vault}\", ..)` and re-run `cindy secret seal`."
                        ))?;
                let value: T = postcard::from_bytes(&payload.value)
                    .context(format!("couldn't deserialise inner T for vault `{vault}`"))?;
                Ok(value)
            }
        }
    }
}

impl<T> fmt::Debug for Secret<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Plain(_) => write!(f, "<secret plain>"),
            Self::Sealed { vault, .. } => write!(f, "<secret vault={vault}>"),
        }
    }
}

impl<T: Clone> Clone for Secret<T> {
    fn clone(&self) -> Self {
        match self {
            Self::Plain(v) => Self::Plain(v.clone()),
            Self::Sealed { vault, ciphertext } => Self::Sealed {
                vault: vault.clone(),
                ciphertext: ciphertext.clone(),
            },
        }
    }
}

// Note: deliberately no `Drop` impl. We'd want one for zeroizing the
// ciphertext buffer, but auto-`Drop` makes `reveal(self)` impossible
// (you can't move out of a type that implements `Drop`). Ciphertext is
// non-secret material once it's left our process anyway; the
// load-bearing zeroize is on the plaintext that lives inside the
// `Zeroizing<Vec<u8>>` returned by [`crypto::unseal`] during a
// `.reveal()` call. For `Plain(T)` containing sensitive data, callers
// should make sure `T` itself uses zeroize-aware types.

// ---- serde ---------------------------------------------------------
//
// The on-the-wire shape is **always** the sealed form. `Plain` values
// serialise by encrypting on the fly (so JSON dumps from a dev `main`
// don't leak plaintext to disk). The `vault` lookup pulls a DEK from
// the keychain at serialisation time — note this means *Plain* secrets
// can't be serialised by code that doesn't have the vault key loaded;
// that's a deliberate guardrail, not a bug.
//
// Deserialisation always produces `Sealed`; revealing requires an
// explicit `.reveal()` call.

#[derive(Serialize, Deserialize)]
struct WireForm<'a, T> {
    vault: std::borrow::Cow<'a, str>,
    /// Base64 of the AEAD blob (nonce || ciphertext || tag).
    ciphertext: std::borrow::Cow<'a, str>,

    _phantom: PhantomData<T>,
}

impl<T: Serialize> Serialize for Secret<T> {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::Error as _;
        match self {
            Self::Sealed { vault, ciphertext } => {
                let b64 = base64::engine::general_purpose::STANDARD.encode(ciphertext);
                WireForm {
                    vault: std::borrow::Cow::Borrowed(vault),
                    ciphertext: std::borrow::Cow::Owned(b64),
                    _phantom: PhantomData::<T>,
                }
                .serialize(serializer)
            }
            Self::Plain(_) => Err(S::Error::custom(
                "refusing to serialise a `Secret::Plain(_)`. \
                 Run `cindy secret seal` to turn it into `Secret::Sealed { .. }` \
                 before persisting it (or any inventory containing it).",
            )),
        }
    }
}

impl<'de, T> Deserialize<'de> for Secret<T> {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        use serde::de::Error as _;
        let wire = WireForm::<T>::deserialize(deserializer)?;
        let ciphertext = base64::engine::general_purpose::STANDARD
            .decode(wire.ciphertext.as_bytes())
            .map_err(D::Error::custom)?;
        Ok(Self::Sealed {
            vault: wire.vault.into_owned(),
            ciphertext,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::{Deserialize, Serialize};

    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
    struct Creds {
        user: String,
        pw: String,
    }

    #[test]
    fn debug_never_leaks_plain() {
        let s = Secret::new(Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        });
        let out = format!("{s:?}");
        assert!(!out.contains("alice"));
        assert!(!out.contains("hunter2"));
        assert_eq!(out, "<secret plain>");
    }

    #[test]
    fn debug_includes_vault_for_sealed() {
        let s: Secret<Creds> = Secret::sealed("prod", b"ignored".to_vec());
        let out = format!("{s:?}");
        assert!(out.contains("prod"));
    }

    #[test]
    fn refuses_to_serialise_plain() {
        let s = Secret::new(Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        });
        assert!(serde_json::to_string(&s).is_err());
    }

    #[test]
    fn sealed_round_trips_through_json() {
        let s: Secret<()> = Secret::sealed("prod", vec![1, 2, 3, 4]);
        let j = serde_json::to_string(&s).unwrap();
        let back: Secret<()> = serde_json::from_str(&j).unwrap();
        assert!(matches!(back, Secret::Sealed { ref vault, ref ciphertext }
                              if vault == "prod" && ciphertext == &[1, 2, 3, 4]));
    }

    #[test]
    fn reveal_plain_is_identity() {
        let s = Secret::new(Creds {
            user: "u".into(),
            pw: "p".into(),
        });
        let revealed = s.reveal().unwrap();
        assert_eq!(revealed.user, "u");
        assert_eq!(revealed.pw, "p");
    }

    #[test]
    fn reveal_sealed_roundtrip_with_installed_dek() {
        let dek = crypto::generate_dek();
        let creds = Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        };
        let payload = SealedPayload {
            value: postcard::to_allocvec(&creds).unwrap(),
            source: "Creds { user: \"alice\".into(), pw: \"hunter2\".into() }".to_owned(),
        };
        let payload_bytes = postcard::to_allocvec(&payload).unwrap();
        let ciphertext = crypto::seal(&dek, &payload_bytes).unwrap();

        let mut chain = std::collections::HashMap::new();
        chain.insert("test-reveal".to_owned(), dek);
        keychain::install_keys(chain);

        let s: Secret<Creds> = Secret::sealed("test-reveal", ciphertext);
        let revealed = s.reveal().unwrap();
        assert_eq!(revealed, creds);
    }
}