cindy 0.1.1

Managing infrastructure at breakneck speed.
Documentation
//! Runtime lookup of vault data-encryption keys (DEKs).
//!
//! V1 model — deliberately as boring as `ansible-vault`'s
//! `--vault-password-file`:
//!
//!   * Each vault `<name>` has a 32-byte key stored at `keys/<name>.dek`
//!     (relative to the orchestrator's working directory, overridable
//!     via the `CINDY_KEYS_DIR` env var).
//!   * `Secret::reveal()` reads `keys/<vault>.dek` on first use and
//!     caches the bytes in a process-global map.
//!   * Operators distribute `keys/<name>.dek` to teammates out-of-band
//!     (Signal, secure file copy, whatever) and `.gitignore` the
//!     directory so it never lands in the repo.
//!
//! On the worker side, the orchestrator marshals the DEKs for the
//! vaults a host's secrets reference into the `CINDY_VAULT_KEYS` env var
//! (a base64-encoded JSON map). [`load_remote_keys_from_env`] picks
//! those up at startup.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use base64::Engine as _;

use crate::Context;
use crate::secret::crypto::{DEK_LEN, Dek};

const DEFAULT_KEYS_DIR: &str = "keys";

/// Process-global keychain. Populated lazily as `Secret::reveal` calls
/// land. Thread-safe; cheap to look up; never serialised.
static KEYCHAIN: Mutex<Option<HashMap<String, Dek>>> = Mutex::new(None);

fn cache_get(vault: &str) -> Option<Dek> {
    KEYCHAIN.lock().ok()?.as_ref()?.get(vault).cloned()
}

fn cache_put(vault: String, dek: Dek) {
    let mut guard = KEYCHAIN.lock().expect("keychain mutex poisoned");
    guard.get_or_insert_with(HashMap::new).insert(vault, dek);
}

/// Bulk-install DEKs into the in-memory keychain. Used on the worker
/// side, where we get the DEKs in pre-decoded form from the
/// orchestrator through an env var.
pub fn install_keys(map: HashMap<String, Dek>) {
    let mut guard = KEYCHAIN.lock().expect("keychain mutex poisoned");
    let chain = guard.get_or_insert_with(HashMap::new);
    for (k, v) in map {
        chain.insert(k, v);
    }
}

/// Worker-side helper: if `CINDY_VAULT_KEYS` is set, decode the JSON
/// `{vault: base64-DEK}` map and install it into the keychain. Called
/// by the macro-generated `main`.
pub fn load_remote_keys_from_env() -> crate::Result<()> {
    let Some(raw) = std::env::var_os("CINDY_VAULT_KEYS") else {
        return Ok(());
    };
    let raw = raw
        .into_string()
        .map_err(|_| anyhow_serde::Error::msg("CINDY_VAULT_KEYS is not valid UTF-8"))?;

    let table: HashMap<String, String> = serde_json::from_str(&raw)
        .context("CINDY_VAULT_KEYS was not a JSON map of {vault: base64-DEK}")?;

    let mut out = HashMap::new();
    for (vault, b64) in table {
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&b64)
            .context(format!("CINDY_VAULT_KEYS[{vault}] is not valid base64"))?;
        if bytes.len() != DEK_LEN {
            crate::bail!(
                "CINDY_VAULT_KEYS[{vault}] has the wrong byte length ({} \u{2260} {DEK_LEN})",
                bytes.len()
            );
        }
        let mut arr = [0u8; DEK_LEN];
        arr.copy_from_slice(&bytes);
        out.insert(vault, zeroize::Zeroizing::new(arr));
    }
    install_keys(out);
    Ok(())
}

/// Resolve the keys directory. Defaults to `./keys`, overridable via
/// `CINDY_KEYS_DIR` (useful for tests and air-gapped deployments).
pub fn keys_dir() -> PathBuf {
    std::env::var_os("CINDY_KEYS_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(DEFAULT_KEYS_DIR))
}

pub fn dek_path(vault: &str) -> PathBuf {
    keys_dir().join(format!("{vault}.dek"))
}

fn read_dek_file(path: &Path) -> crate::Result<Option<Dek>> {
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e).context(format!("Reading {}", path.display())),
    };
    if bytes.len() != DEK_LEN {
        crate::bail!(
            "{} has the wrong size ({} bytes, expected {DEK_LEN}). \
             Either it isn't a cindy DEK file, or it got truncated. \
             Delete it and re-run `cindy secret vault create <name>` \
             (or restore it from wherever you have a backup).",
            path.display(),
            bytes.len()
        );
    }
    let mut arr = [0u8; DEK_LEN];
    arr.copy_from_slice(&bytes);
    Ok(Some(zeroize::Zeroizing::new(arr)))
}

/// Public entry point: get the DEK for `vault`. Looks at the in-memory
/// cache first, then falls back to the on-disk file. Returns an error
/// if no key is available — at runtime that means the operator forgot
/// to provision `keys/<vault>.dek` on this machine.
pub fn get_dek(vault: &str) -> crate::Result<Dek> {
    if let Some(dek) = cache_get(vault) {
        return Ok(dek);
    }
    let path = dek_path(vault);
    let Some(dek) = read_dek_file(&path)? else {
        crate::bail!(
            "no key for vault `{vault}` \u{2014} expected {} to exist. \
             Either run `cindy secret vault create {vault}` to bootstrap a new vault, \
             or copy the key file from a teammate who already has one.",
            path.display()
        );
    };
    cache_put(vault.to_owned(), dek.clone());
    Ok(dek)
}