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 _;
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>,
}
crate::__reexports::inventory::collect!(PendingSecret);
#[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> {
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(),
},
}
}
}
#[derive(Serialize, Deserialize)]
struct WireForm<'a, T> {
vault: std::borrow::Cow<'a, str>,
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);
}
}