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";
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);
}
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);
}
}
pub fn decode_env_keys() -> crate::Result<Option<HashMap<String, Vec<u8>>>> {
let Some(raw) = std::env::var_os("CINDY_VAULT_KEYS") else {
return Ok(None);
};
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()
);
}
out.insert(vault, bytes);
}
Ok(Some(out))
}
pub fn install_raw_keys(map: HashMap<String, Vec<u8>>) -> crate::Result<()> {
let mut out = HashMap::new();
for (vault, bytes) in map {
if bytes.len() != DEK_LEN {
crate::bail!(
"DEK for vault `{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(())
}
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)))
}
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)
}