use std::path::Path;
use hmac::{Hmac, KeyInit as _, Mac as _};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const ENTRY_VERSION: u64 = 2;
const V2_DOMAIN: &str = "leanctx.audit.v2\n";
pub use lean_ctx_protocol::hive::receipts_v2::HMAC_PREFIX;
const KEY_FILE: &str = "receipts.hmac-key";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditDecision {
#[default]
Allow,
Deny,
Redact,
Error,
}
impl AuditDecision {
pub fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Deny => "deny",
Self::Redact => "redact",
Self::Error => "error",
}
}
fn known(label: &str) -> bool {
matches!(label, "allow" | "deny" | "redact" | "error")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionFacts {
pub session_id: String,
pub instance_id: Option<String>,
pub policy_digest: Option<String>,
pub decision: AuditDecision,
pub output_digest: Option<String>,
}
pub(super) fn v2_fields(value: &serde_json::Value) -> Option<serde_json::Value> {
use serde_json::Value;
let text = |key: &str| value.get(key).and_then(Value::as_str);
let optional = |key: &str, valid: &dyn Fn(&Value) -> bool| match value.get(key) {
None | Some(Value::Null) => Some(Value::Null),
Some(v) if valid(v) => Some(v.clone()),
Some(_) => None,
};
let is_text = |v: &Value| v.is_string();
let session_id = optional("session_id", &|v| v.as_str().is_some_and(|s| !s.is_empty()))?;
let seq = optional("seq", &|v| v.as_u64().is_some_and(|n| n >= 1))?;
if session_id.is_null() != seq.is_null() {
return None;
}
let decision = optional("decision", &|v| {
v.as_str().is_some_and(AuditDecision::known)
})?;
Some(serde_json::json!({
"decision": decision,
"entry_hash": text("entry_hash")?,
"instance_id": optional("instance_id", &is_text)?,
"output_digest": optional("output_digest", &is_text)?,
"policy_digest": optional("policy_digest", &is_text)?,
"seq": seq,
"session_id": session_id,
"timestamp": text("timestamp")?,
"v": ENTRY_VERSION,
}))
}
pub(super) fn compute_v2_hash(head: &str, fields: &serde_json::Value) -> String {
let mut hasher = Sha256::new();
hasher.update(V2_DOMAIN.as_bytes());
hasher.update(head.as_bytes());
hasher.update(serde_json::to_string(fields).unwrap_or_default().as_bytes());
crate::core::agent_identity::hex_encode(&hasher.finalize())
}
pub(super) fn version_of(value: &serde_json::Value) -> Option<u64> {
match value.get("v") {
None => Some(1),
Some(v) => v.as_u64(),
}
}
pub(super) fn head_of(value: &serde_json::Value, version: u64) -> Option<String> {
let field = |key: &str| value.get(key).and_then(serde_json::Value::as_str);
let head = if version >= ENTRY_VERSION {
field("v2_hash").or_else(|| field("entry_hash"))
} else {
field("entry_hash")
};
head.map(str::to_string)
}
pub(super) fn next_seq(session_id: &str) -> u64 {
static SESSION_SEQ: std::sync::Mutex<Option<std::collections::HashMap<String, u64>>> =
std::sync::Mutex::new(None);
let mut guard = SESSION_SEQ
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let seq = guard
.get_or_insert_with(std::collections::HashMap::new)
.entry(session_id.to_string())
.or_insert(0);
*seq += 1;
*seq
}
pub fn load_or_create_hmac_key(dir: &Path) -> Option<[u8; 32]> {
if let Some(key) = load_hmac_key(dir) {
return Some(key);
}
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).ok()?;
crate::core::secure_fs::write_private(dir, KEY_FILE, hex::encode(bytes).as_bytes()).ok()?;
load_hmac_key(dir)
}
fn load_hmac_key(dir: &Path) -> Option<[u8; 32]> {
let text = std::fs::read_to_string(dir.join(KEY_FILE)).ok()?;
hex::decode(text.trim()).ok()?.try_into().ok()
}
pub fn hmac_hex(key: &[u8; 32], label: &str, data: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes any key length");
mac.update(label.as_bytes());
mac.update(b"\n");
mac.update(data);
hex::encode(mac.finalize().into_bytes())
}
pub fn keyed_digest(label: &str, data: &[u8]) -> Option<String> {
let dir = crate::core::data_dir::lean_ctx_data_dir()
.ok()?
.join("hive");
let key = load_or_create_hmac_key(&dir)?;
Some(format!("{HMAC_PREFIX}{}", hmac_hex(&key, label, data)))
}
pub fn keyed_input_digest(args: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
keyed_digest("input", serde_json::to_string(args).ok()?.as_bytes())
}