use std::fmt;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde::{Deserializer, Serializer};
pub mod crypto;
pub mod keychain;
use crate::Context as _;
pub enum Secret<T> {
Plain(T),
Sealed { vault: String, ciphertext: Vec<u8> },
}
#[doc(hidden)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SealedPayload {
pub value: Vec<u8>,
pub source: String,
}
impl<T> Secret<T> {
pub fn new(value: T) -> Self {
Self::Plain(value)
}
pub fn sealed(vault: impl Into<String>, ciphertext: Vec<u8>) -> Self {
Self::Sealed {
vault: vault.into(),
ciphertext,
}
}
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,
}
}
pub fn vault(&self) -> Option<&str> {
match self {
Self::Plain(_) => None,
Self::Sealed { vault, .. } => Some(vault.as_str()),
}
}
}
#[doc(hidden)]
pub struct PendingSecret {
pub file: &'static str,
pub line: u32,
pub column: u32,
pub vault: &'static str,
pub serialize: fn() -> Vec<u8>,
}
inventory::collect!(PendingSecret);
#[doc(hidden)]
pub fn registered_vaults() -> Vec<String> {
let mut vaults: Vec<String> = crate::__reexports::inventory::iter::<PendingSecret>()
.map(|p| p.vault.to_owned())
.collect();
vaults.sort();
vaults.dedup();
vaults
}
#[doc(hidden)]
pub fn missing_vaults<I, S>(vaults: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut missing: Vec<String> = vaults
.into_iter()
.filter(|v| keychain::get_dek(v.as_ref()).is_err())
.map(|v| v.as_ref().to_owned())
.collect();
missing.sort();
missing.dedup();
missing
}
#[doc(hidden)]
pub fn preflight(role: &str, host_context_json: Option<&str>) -> crate::Result<()> {
let mut needed: std::collections::BTreeSet<String> = registered_vaults().into_iter().collect();
if let Some(json) = host_context_json
&& let Ok(value) =
crate::__reexports::serde_json::from_str::<crate::__reexports::serde_json::Value>(json)
{
crate::inventory::collect_sealed_vaults(&value, &mut needed);
}
let missing = missing_vaults(&needed);
if missing.is_empty() {
return Ok(());
}
crate::bail!(
"missing decryption keys before {role} could start: \
no DEK available for vault(s) {missing:?}. \
Provision the key file(s) (`cindy secret vault create <name>`, \
or copy `keys/<name>.dek` from a teammate) and re-run. \
Failing now rather than partway through the play."
)
}
#[allow(non_snake_case, unused)]
#[deprecated(
since = "0.0.0",
note = "\n\n⚠️ Unsealed secret. Run `cindy secret seal`.\n\n"
)]
#[doc(hidden)]
pub fn UNSEALED_SECRET() {}
#[macro_export]
macro_rules! secret {
($vault:literal, $value:expr $(,)?) => {{
#[allow(non_upper_case_globals)]
const __CINDY_SECRET_VALUES_MUST_BE_SELF_CONTAINED: fn() -> ::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_VALUES_MUST_BE_SELF_CONTAINED,
}
}
$crate::secret::UNSEALED_SECRET();
$crate::Secret::new($value)
}};
}
impl<T: serde::de::DeserializeOwned> Secret<T> {
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)
}
}
}
}
thread_local! {
static REVEAL_SECRETS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn revealing() -> bool {
REVEAL_SECRETS.with(|c| c.get())
}
pub fn with_revealed_secrets<R>(f: impl FnOnce() -> R) -> R {
REVEAL_SECRETS.with(|c| {
let prev = c.replace(true);
let out = f();
c.set(prev);
out
})
}
impl<T: serde::de::DeserializeOwned> Secret<T> {
fn reveal_ref(&self) -> crate::Result<T> {
match self {
Self::Plain(_) => crate::bail!("cannot reveal a `Plain` secret by reference"),
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}`"
))?;
let value: T = postcard::from_bytes(&payload.value)
.context(format!("couldn't deserialise inner T for vault `{vault}`"))?;
Ok(value)
}
}
}
}
impl<T: fmt::Debug + serde::de::DeserializeOwned> fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if revealing() {
match self.reveal_ref() {
Ok(value) => return value.fmt(f),
Err(e) => return write!(f, "<secret reveal failed: {e}>"),
}
}
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(),
},
}
}
}
#[derive(Serialize, Deserialize)]
struct WireForm<'a> {
vault: std::borrow::Cow<'a, str>,
ciphertext: std::borrow::Cow<'a, str>,
}
impl<T: Serialize + serde::de::DeserializeOwned> Serialize for Secret<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::Error as _;
if revealing() {
let value = self.reveal_ref().map_err(S::Error::custom)?;
return value.serialize(serializer);
}
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),
}
.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::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);
}
fn seal_creds(vault: &str, creds: &Creds) -> Vec<u8> {
let dek = crypto::generate_dek();
let payload = SealedPayload {
value: postcard::to_allocvec(creds).unwrap(),
source: "ignored".to_owned(),
};
let ciphertext = crypto::seal(&dek, &postcard::to_allocvec(&payload).unwrap()).unwrap();
let mut chain = std::collections::HashMap::new();
chain.insert(vault.to_owned(), dek);
keychain::install_keys(chain);
ciphertext
}
#[test]
fn debug_reveal_prints_real_value_only_inside_scope() {
let creds = Creds {
user: "bob".into(),
pw: "s3cr3t".into(),
};
let s: Secret<Creds> = Secret::sealed("dbg-reveal", seal_creds("dbg-reveal", &creds));
assert_eq!(format!("{s:?}"), "<secret vault=dbg-reveal>");
let revealed = with_revealed_secrets(|| format!("{s:?}"));
assert_eq!(revealed, format!("{creds:?}"));
assert!(revealed.contains("s3cr3t"));
assert_eq!(format!("{s:?}"), "<secret vault=dbg-reveal>");
}
#[test]
fn serialize_reveal_emits_plaintext_value_inside_scope() {
let creds = Creds {
user: "carol".into(),
pw: "pw123".into(),
};
let s: Secret<Creds> = Secret::sealed("ser-reveal", seal_creds("ser-reveal", &creds));
let sealed_json = serde_json::to_value(&s).unwrap();
assert!(sealed_json.get("ciphertext").is_some());
assert!(!sealed_json.to_string().contains("pw123"));
let revealed = with_revealed_secrets(|| serde_json::to_value(&s).unwrap());
assert_eq!(revealed, serde_json::to_value(&creds).unwrap());
assert_eq!(revealed["pw"], "pw123");
}
#[test]
fn collect_sealed_vaults_finds_nested_secrets() {
let value = crate::inventory::Host {
name: "h1".into(),
tags: crate::tags![],
vars: serde_json::json!({
"api_token": { "vault": "prod", "ciphertext": "AAAA" },
"nested": {
"deep": { "vault": "staging", "ciphertext": "BBBB" }
},
"list": [
{ "vault": "prod", "ciphertext": "CCCC" },
{ "vault": "ci", "ciphertext": "DDDD" }
],
"decoy_vault_only": { "vault": "not-a-secret" },
"decoy_ct_only": { "ciphertext": "no-vault" }
}),
};
let mut out = std::collections::BTreeSet::new();
crate::inventory::collect_sealed_vaults(&serde_json::to_value(value).unwrap(), &mut out);
let got: Vec<&str> = out.iter().map(String::as_str).collect();
assert_eq!(got, vec!["ci", "prod", "staging"]);
}
#[test]
fn missing_vaults_reports_unavailable_only() {
let dek = crypto::generate_dek();
let mut chain = std::collections::HashMap::new();
chain.insert("present-vault".to_owned(), dek);
keychain::install_keys(chain);
let missing = missing_vaults(["present-vault", "absent-vault-xyz"]);
assert_eq!(missing, vec!["absent-vault-xyz".to_owned()]);
}
#[test]
fn debug_reveal_failure_does_not_leak_and_does_not_panic() {
let s: Secret<Creds> = Secret::sealed("no-such-vault-for-debug", vec![9, 9, 9]);
let out = with_revealed_secrets(|| format!("{s:?}"));
assert!(out.starts_with("<secret reveal failed:"));
}
}