Skip to main content

lean_ctx/core/audit_trail/
v2.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Audit entry v2 (ent3-1): session binding, hashed time and decision facts.
3//!
4//! Every entry this build writes is a v2 entry: a v1 entry plus `v: 2`,
5//! its `timestamp` under the hash, and, when it records a call of a session,
6//! the facts of [`SessionFacts`]: `session_id`, `seq` (per session, from 1),
7//! `instance_id`, `policy_digest`, `decision` and `output_digest` (`null`
8//! outside a session). The v1 `entry_hash` stays exactly as it was: it
9//! covers the v1 field set and links through `prev_hash`, so every reader
10//! that predates v2 (an older engine, `leanctx-verify`, the evidence bundle)
11//! still verifies a mixed trail and never reports a v2 entry as tampering.
12//!
13//! The v2 facts are bound by a second chained hash, `v2_hash`:
14//!
15//! ```text
16//! v2_hash = sha256("leanctx.audit.v2\n" ‖ head ‖ canonical_json(v2 fields))
17//! ```
18//!
19//! where `head` is the chain head before the entry (the `v2_hash` of the
20//! previous entry when it is a v2 entry, else its `entry_hash`; `genesis` at
21//! the start) and the v2 fields are the facts above plus `timestamp`, `v`
22//! and the entry's own `entry_hash`, in sorted key order with absent facts
23//! as `null`. Editing a timestamp, seq or any other fact breaks that entry,
24//! and rewriting its `v2_hash` breaks the next v2 entry: the property the v1
25//! chain gives the v1 fields. A v1 entry appended by an older writer
26//! restarts the head at its own `entry_hash`; the v1 chain still links
27//! through it. Like the v1 chain, the chain alone cannot protect the last
28//! entry (it can be cut, or stripped back to v1): the signed per-session
29//! proof over the head (ent3-2) closes that.
30//!
31//! Input and output digests of v2 entries are HMAC-SHA256 under the
32//! engine-local key `<data_dir>/hive/receipts.hmac-key` (the key receipts v2
33//! already use, created on first use, never uploaded), so a digest that
34//! leaves the machine cannot be tested against a guessed input.
35
36use std::path::Path;
37
38use hmac::{Hmac, KeyInit as _, Mac as _};
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41
42/// The entry version this module writes.
43pub const ENTRY_VERSION: u64 = 2;
44/// Domain of the `v2_hash` chain.
45const V2_DOMAIN: &str = "leanctx.audit.v2\n";
46/// Prefix of a keyed digest, the one receipts v2 carry.
47pub use lean_ctx_protocol::hive::receipts_v2::HMAC_PREFIX;
48/// The engine-local HMAC key file inside `<data_dir>/hive`.
49const KEY_FILE: &str = "receipts.hmac-key";
50
51/// What the gateway decided for the call an entry records.
52#[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/// The session facts a v2 entry carries. `seq` is not here: the writer
78/// assigns it per session, under the trail lock, in chain order.
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80pub struct SessionFacts {
81    pub session_id: String,
82    /// The Hive instance id when this installation is attached.
83    pub instance_id: Option<String>,
84    /// Digest of the policy that decided the call (ent2).
85    pub policy_digest: Option<String>,
86    pub decision: AuditDecision,
87    /// Keyed digest of the output ([`keyed_digest`]).
88    pub output_digest: Option<String>,
89}
90
91/// The v2 hashed field set of a raw v2 entry, or `None` when a fact is
92/// missing or malformed (a structural defect, reported as invalid).
93pub(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    // A session entry has both, any other entry neither.
105    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    // Sorted keys, built in sorted order: the same bytes with or without
112    // serde_json's `preserve_order`.
113    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
126/// `v2_hash` of an entry whose v2 fields are `fields`, after chain head
127/// `head`.
128pub(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
136/// The entry version of a raw entry: 1 when the field is absent.
137pub(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
144/// The chain head an entry of `version` leaves behind: the `v2_hash` of a
145/// v2 entry (or of a later version that kept the field), else the
146/// `entry_hash`.
147pub(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
157/// Next `seq` of `session_id` in this process. Called under the exclusive
158/// trail lock, so the numbers follow chain order.
159pub(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
173/// The engine-local HMAC key in `dir` (32 random bytes, hex), created on
174/// first use. Shared with Hive receipts v2.
175pub 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    // Another process may have written first: the file wins.
183    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
191/// HMAC-SHA256(key, `label` ‖ `\n` ‖ `data`), lowercase hex.
192pub 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
200/// `hmac-sha256:<hex>` of `data` under this installation's local key, or
201/// `None` when the key cannot be read or created (the entry then carries no
202/// digest rather than a guessable one).
203pub 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
211/// Keyed digest of tool arguments (the v2 `input_hash`).
212pub 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}