lean_ctx/core/audit_trail/
v2.rs1use std::path::Path;
37
38use hmac::{Hmac, KeyInit as _, Mac as _};
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41
42pub const ENTRY_VERSION: u64 = 2;
44const V2_DOMAIN: &str = "leanctx.audit.v2\n";
46pub use lean_ctx_protocol::hive::receipts_v2::HMAC_PREFIX;
48const KEY_FILE: &str = "receipts.hmac-key";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum AuditDecision {
55 #[default]
56 Allow,
57 Deny,
58 Redact,
59 Error,
60}
61
62impl AuditDecision {
63 pub fn as_str(self) -> &'static str {
64 match self {
65 Self::Allow => "allow",
66 Self::Deny => "deny",
67 Self::Redact => "redact",
68 Self::Error => "error",
69 }
70 }
71
72 fn known(label: &str) -> bool {
73 matches!(label, "allow" | "deny" | "redact" | "error")
74 }
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct SessionFacts {
81 pub session_id: String,
82 pub instance_id: Option<String>,
84 pub policy_digest: Option<String>,
86 pub decision: AuditDecision,
87 pub output_digest: Option<String>,
89}
90
91pub(super) fn v2_fields(value: &serde_json::Value) -> Option<serde_json::Value> {
94 use serde_json::Value;
95 let text = |key: &str| value.get(key).and_then(Value::as_str);
96 let optional = |key: &str, valid: &dyn Fn(&Value) -> bool| match value.get(key) {
97 None | Some(Value::Null) => Some(Value::Null),
98 Some(v) if valid(v) => Some(v.clone()),
99 Some(_) => None,
100 };
101 let is_text = |v: &Value| v.is_string();
102 let session_id = optional("session_id", &|v| v.as_str().is_some_and(|s| !s.is_empty()))?;
103 let seq = optional("seq", &|v| v.as_u64().is_some_and(|n| n >= 1))?;
104 if session_id.is_null() != seq.is_null() {
106 return None;
107 }
108 let decision = optional("decision", &|v| {
109 v.as_str().is_some_and(AuditDecision::known)
110 })?;
111 Some(serde_json::json!({
114 "decision": decision,
115 "entry_hash": text("entry_hash")?,
116 "instance_id": optional("instance_id", &is_text)?,
117 "output_digest": optional("output_digest", &is_text)?,
118 "policy_digest": optional("policy_digest", &is_text)?,
119 "seq": seq,
120 "session_id": session_id,
121 "timestamp": text("timestamp")?,
122 "v": ENTRY_VERSION,
123 }))
124}
125
126pub(super) fn compute_v2_hash(head: &str, fields: &serde_json::Value) -> String {
129 let mut hasher = Sha256::new();
130 hasher.update(V2_DOMAIN.as_bytes());
131 hasher.update(head.as_bytes());
132 hasher.update(serde_json::to_string(fields).unwrap_or_default().as_bytes());
133 crate::core::agent_identity::hex_encode(&hasher.finalize())
134}
135
136pub(super) fn version_of(value: &serde_json::Value) -> Option<u64> {
138 match value.get("v") {
139 None => Some(1),
140 Some(v) => v.as_u64(),
141 }
142}
143
144pub(super) fn head_of(value: &serde_json::Value, version: u64) -> Option<String> {
148 let field = |key: &str| value.get(key).and_then(serde_json::Value::as_str);
149 let head = if version >= ENTRY_VERSION {
150 field("v2_hash").or_else(|| field("entry_hash"))
151 } else {
152 field("entry_hash")
153 };
154 head.map(str::to_string)
155}
156
157pub(super) fn next_seq(session_id: &str) -> u64 {
160 static SESSION_SEQ: std::sync::Mutex<Option<std::collections::HashMap<String, u64>>> =
161 std::sync::Mutex::new(None);
162 let mut guard = SESSION_SEQ
163 .lock()
164 .unwrap_or_else(std::sync::PoisonError::into_inner);
165 let seq = guard
166 .get_or_insert_with(std::collections::HashMap::new)
167 .entry(session_id.to_string())
168 .or_insert(0);
169 *seq += 1;
170 *seq
171}
172
173pub fn load_or_create_hmac_key(dir: &Path) -> Option<[u8; 32]> {
176 if let Some(key) = load_hmac_key(dir) {
177 return Some(key);
178 }
179 let mut bytes = [0u8; 32];
180 getrandom::fill(&mut bytes).ok()?;
181 crate::core::secure_fs::write_private(dir, KEY_FILE, hex::encode(bytes).as_bytes()).ok()?;
182 load_hmac_key(dir)
184}
185
186fn load_hmac_key(dir: &Path) -> Option<[u8; 32]> {
187 let text = std::fs::read_to_string(dir.join(KEY_FILE)).ok()?;
188 hex::decode(text.trim()).ok()?.try_into().ok()
189}
190
191pub fn hmac_hex(key: &[u8; 32], label: &str, data: &[u8]) -> String {
193 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes any key length");
194 mac.update(label.as_bytes());
195 mac.update(b"\n");
196 mac.update(data);
197 hex::encode(mac.finalize().into_bytes())
198}
199
200pub fn keyed_digest(label: &str, data: &[u8]) -> Option<String> {
204 let dir = crate::core::data_dir::lean_ctx_data_dir()
205 .ok()?
206 .join("hive");
207 let key = load_or_create_hmac_key(&dir)?;
208 Some(format!("{HMAC_PREFIX}{}", hmac_hex(&key, label, data)))
209}
210
211pub fn keyed_input_digest(args: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
213 keyed_digest("input", serde_json::to_string(args).ok()?.as_bytes())
214}