use std::collections::BTreeMap;
use anyhow::{bail, Context, Result};
use hmac::Mac;
pub const KEY_ENV: &str = "YAH_ENV_FINGERPRINT_KEY";
pub const VARS_ENV: &str = "YAH_ENV_FINGERPRINT_VARS";
pub const ROOT_SLOT: &str = "env-fingerprint-key";
const DERIVATION_CONTEXT: &str = "yah/env-fingerprint/v1";
pub fn fingerprint(key: &str, value: &str) -> String {
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(key.as_bytes())
.expect("HMAC accepts a key of any length");
mac.update(value.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
pub fn mirror_key(root: &str, service: &str, env: &str) -> String {
fingerprint(root, &format!("{DERIVATION_CONTEXT}/{service}/{env}"))
}
pub fn mint_root() -> Result<String> {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).context("reading the OS random source")?;
Ok(hex::encode(bytes))
}
pub fn stamp<'a>(
serve_env: &mut BTreeMap<String, String>,
declared: impl IntoIterator<Item = &'a String>,
key: &str,
) -> Result<()> {
for reserved in [KEY_ENV, VARS_ENV] {
if serve_env.contains_key(reserved) {
bail!(
"{reserved} is reserved — `yah cloud apply` sets it to report credential drift \
(R866). Remove it from [providers.bundle.env]."
);
}
}
let names: Vec<&str> = declared.into_iter().map(String::as_str).collect();
if names.is_empty() {
return Ok(());
}
serve_env.insert(KEY_ENV.to_string(), key.to_string());
serve_env.insert(VARS_ENV.to_string(), names.join(","));
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
InSync,
Drifted,
NotRunning,
NoLongerDeclared,
NotReported,
Unresolved,
}
impl Verdict {
pub fn is_drift(&self) -> bool {
matches!(self, Self::Drifted | Self::NotRunning | Self::NoLongerDeclared)
}
pub fn needs_attention(&self) -> bool {
*self != Self::InSync
}
pub fn label(&self) -> &'static str {
match self {
Self::InSync => "in sync",
Self::Drifted => "DRIFT: running value differs from the vault",
Self::NotRunning => "DRIFT: declared, not in the running process",
Self::NoLongerDeclared => "DRIFT: running, no longer declared",
Self::NotReported => "unverified: workload reports no fingerprints",
Self::Unresolved => "unverified: source does not resolve here",
}
}
}
pub fn compare(
key: &str,
expected: &BTreeMap<String, Option<String>>,
reported: &BTreeMap<String, String>,
) -> BTreeMap<String, Verdict> {
let mut out: BTreeMap<String, Verdict> = expected
.iter()
.map(|(name, value)| {
let verdict = if reported.is_empty() {
Verdict::NotReported
} else {
match (value, reported.get(name)) {
(None, _) => Verdict::Unresolved,
(Some(_), None) => Verdict::NotRunning,
(Some(v), Some(fp)) if fingerprint(key, v) == *fp => Verdict::InSync,
(Some(_), Some(_)) => Verdict::Drifted,
}
};
(name.clone(), verdict)
})
.collect();
for name in reported.keys() {
out.entry(name.clone()).or_insert(Verdict::NoLongerDeclared);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_matches_the_cross_language_vector() {
assert_eq!(
fingerprint("yah-test-key", "s3cr3t-value"),
"d09f5c67f9abb684a6ec49836260cac2e1d44d86006107e2efc47d381ad93635"
);
}
#[test]
fn the_mirror_key_derivation_is_stable() {
assert_eq!(
mirror_key(&"00".repeat(32), "yah-analytics", "prod"),
"a901d03bb7f4acd80336f496fe0e6461bf29de5444ee78245f2eab6c2021320c"
);
let root = "00".repeat(32);
assert_ne!(
mirror_key(&root, "yah-analytics", "prod"),
mirror_key(&root, "yah-analytics", "staging"),
"one mirror's key must not unlock another's fingerprints"
);
}
#[test]
fn a_minted_root_is_32_random_bytes() {
let (a, b) = (mint_root().unwrap(), mint_root().unwrap());
assert_eq!(a.len(), 64);
assert_ne!(a, b);
}
#[test]
fn stamp_lists_only_declared_names_and_never_their_values() {
let declared: BTreeMap<String, String> = [
("R2_SECRET".to_string(), "vault:cloudflare-r2-secret-key".to_string()),
("R2_ENDPOINT".to_string(), "vault:cloudflare-r2-endpoint".to_string()),
]
.into();
let mut env: BTreeMap<String, String> = [
("R2_SECRET".to_string(), "hunter2-the-secret".to_string()),
("R2_ENDPOINT".to_string(), "https://r2.example".to_string()),
("MESOFACT_ROUTE_HEADERS".to_string(), "[]".to_string()),
]
.into();
stamp(&mut env, declared.keys(), "mirror-key").unwrap();
assert_eq!(env[KEY_ENV], "mirror-key");
assert_eq!(env[VARS_ENV], "R2_ENDPOINT,R2_SECRET");
assert!(!env[VARS_ENV].contains("hunter2"));
}
#[test]
fn stamp_adds_nothing_when_nothing_is_declared() {
let mut env = BTreeMap::new();
stamp(&mut env, std::iter::empty(), "k").unwrap();
assert!(env.is_empty());
}
#[test]
fn a_mirror_may_not_declare_the_reserved_names() {
let mut env: BTreeMap<String, String> = [(KEY_ENV.to_string(), "mine".to_string())].into();
let err = stamp(&mut env, std::iter::empty(), "k").unwrap_err();
assert!(err.to_string().contains("reserved"), "{err}");
}
fn expected(pairs: &[(&str, Option<&str>)]) -> BTreeMap<String, Option<String>> {
pairs
.iter()
.map(|(n, v)| (n.to_string(), v.map(str::to_string)))
.collect()
}
#[test]
fn a_rotated_vault_value_reads_as_drift() {
let reported = [("SECRET".to_string(), fingerprint("k", "old-value"))].into();
let got = compare("k", &expected(&[("SECRET", Some("new-value"))]), &reported);
assert_eq!(got["SECRET"], Verdict::Drifted);
assert!(got["SECRET"].is_drift());
}
#[test]
fn a_matching_value_is_in_sync() {
let reported = [("SECRET".to_string(), fingerprint("k", "v"))].into();
let got = compare("k", &expected(&[("SECRET", Some("v"))]), &reported);
assert_eq!(got["SECRET"], Verdict::InSync);
assert!(!got["SECRET"].needs_attention());
}
#[test]
fn declared_unreported_and_reported_undeclared_are_both_drift() {
let reported = [
("KEPT".to_string(), fingerprint("k", "v")),
("REMOVED".to_string(), fingerprint("k", "x")),
]
.into();
let got = compare(
"k",
&expected(&[("KEPT", Some("v")), ("ADDED", Some("y"))]),
&reported,
);
assert_eq!(got["KEPT"], Verdict::InSync);
assert_eq!(got["ADDED"], Verdict::NotRunning);
assert_eq!(got["REMOVED"], Verdict::NoLongerDeclared);
}
#[test]
fn a_workload_that_reports_nothing_is_unverified_not_clean() {
let got = compare("k", &expected(&[("SECRET", Some("v"))]), &BTreeMap::new());
assert_eq!(got["SECRET"], Verdict::NotReported);
assert!(!got["SECRET"].is_drift());
assert!(got["SECRET"].needs_attention());
}
#[test]
fn an_unresolvable_source_is_unverified() {
let reported = [("SECRET".to_string(), fingerprint("k", "v"))].into();
let got = compare("k", &expected(&[("SECRET", None)]), &reported);
assert_eq!(got["SECRET"], Verdict::Unresolved);
assert!(!got["SECRET"].is_drift());
}
}