Skip to main content

av_harness/
journal.rs

1//! Authenticated envelopes for active-workflow crash journals.
2
3use hmac::{Hmac, Mac as _};
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use sha2::Sha256;
7
8type HmacSha256 = Hmac<Sha256>;
9
10/// Journal-domain prefixes used by `seal` / `open` to bind an envelope to its
11/// intended purpose. Callers pass one of these to prevent cross-domain replay.
12pub const LIFECYCLE_OUTBOX_DOMAIN: &str = "lifecycle-outbox";
13pub const TOOL_INTENT_DOMAIN: &str = "tool-intent";
14pub const TOOL_OUTCOME_DOMAIN: &str = "tool-outcome";
15pub const TOOL_AUDITED_DOMAIN: &str = "tool-audited";
16
17/// Lifecycle-outbox kinds (`receipt`, `session-close`) that appear in the
18/// on-disk file name and in the outbox filter.
19pub const RECEIPT_OUTBOX_KIND: &str = "receipt";
20pub const SESSION_CLOSE_OUTBOX_KIND: &str = "session-close";
21
22#[derive(Serialize, Deserialize)]
23struct Envelope {
24    index: u64,
25    payload: serde_json::Value,
26    mac: String,
27}
28
29/// Derive the deployment-local HMAC key used for authenticated control files.
30pub fn key_from_signer(signer: &dyn av_receipts::Signer) -> [u8; 32] {
31    let signature = signer.sign(b"agentvisor-active-journal-key-v1");
32    let mut key = [0u8; 32];
33    key.copy_from_slice(&signature[..32]);
34    key
35}
36
37pub(crate) fn seal<T: Serialize>(
38    key: &[u8; 32],
39    domain: &str,
40    index: u64,
41    value: &T,
42) -> Result<Vec<u8>, String> {
43    let payload = serde_json::to_value(value).map_err(|error| error.to_string())?;
44    let mac = build_mac(key, domain, index, &payload)?.finalize().into_bytes();
45    serde_json::to_vec(&Envelope {
46        index,
47        payload,
48        mac: hex::encode(mac),
49    })
50    .map_err(|error| error.to_string())
51}
52
53pub(crate) fn open<T: DeserializeOwned>(
54    key: &[u8; 32],
55    domain: &str,
56    expected_index: u64,
57    bytes: &[u8],
58) -> Result<T, String> {
59    let envelope: Envelope = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
60    // Round-17 F9: HMAC-SHA256 renders as exactly 64 hex chars. A
61    // fs-tamper attacker with a multi-MB mac field would otherwise
62    // force `hex::decode` to allocate half the string length on
63    // every recovery-scan tick. Cap at 128 (twice the legitimate
64    // length to allow one round of format experimentation).
65    if envelope.mac.len() > 128 {
66        return Err(format!(
67            "journal mac field is {} chars; refusing (HMAC-SHA256 is 64 hex chars)",
68            envelope.mac.len()
69        ));
70    }
71    let claimed = hex::decode(&envelope.mac).map_err(|error| error.to_string())?;
72    // Round-26 F3: verify the MAC BEFORE any index-mismatch branch.
73    // The old order (index check first, MAC last) meant that an
74    // fs-tamper attacker with read access to the journal directory
75    // could probe `expected_index` for every position and learn the
76    // reconciler's on-disk cursor via the disclosed `envelope.index`
77    // and `expected_index` in the error text. Not a forgery hole —
78    // the MAC still guards authenticity — but it's a position
79    // oracle that lets an adversary map the state machine and craft
80    // targeted quarantine denial-of-restore attacks. Verify first,
81    // then compare positions with a generic error.
82    let verifier = build_mac(key, domain, envelope.index, &envelope.payload)?;
83    verifier
84        .verify_slice(&claimed)
85        .map_err(|_| "journal authentication failed".to_owned())?;
86    if envelope.index != expected_index {
87        return Err("journal position mismatch".to_owned());
88    }
89    serde_json::from_value(envelope.payload).map_err(|error| error.to_string())
90}
91
92fn build_mac(
93    key: &[u8; 32],
94    domain: &str,
95    index: u64,
96    payload: &serde_json::Value,
97) -> Result<HmacSha256, String> {
98    let canonical = av_receipts::canonicalize(payload).map_err(|error| error.to_string())?;
99    let mut mac = HmacSha256::new_from_slice(key).map_err(|error| error.to_string())?;
100    mac.update(b"agentvisor-journal-v1\0");
101    mac.update(&(domain.len() as u64).to_be_bytes());
102    mac.update(domain.as_bytes());
103    mac.update(&index.to_be_bytes());
104    mac.update(&(canonical.len() as u64).to_be_bytes());
105    mac.update(canonical.as_bytes());
106    Ok(mac)
107}
108
109#[cfg(test)]
110mod tests {
111    #![allow(clippy::expect_used, clippy::indexing_slicing, clippy::unwrap_used)]
112
113    use super::*;
114
115    #[test]
116    fn mutation_and_reordering_fail_authentication() {
117        let key = [7; 32];
118        let sealed = seal(&key, "session:signed", 3, &serde_json::json!({"value": 1})).unwrap();
119        let value: serde_json::Value = open(&key, "session:signed", 3, &sealed).unwrap();
120        assert_eq!(value["value"], 1);
121        assert!(open::<serde_json::Value>(&key, "session:signed", 2, &sealed).is_err());
122        let mut envelope: serde_json::Value = serde_json::from_slice(&sealed).unwrap();
123        envelope["payload"]["value"] = serde_json::json!(2);
124        assert!(open::<serde_json::Value>(
125            &key,
126            "session:signed",
127            3,
128            &serde_json::to_vec(&envelope).unwrap()
129        )
130        .is_err());
131    }
132
133    /// Every MAC byte mutated in isolation must produce the SAME error
134    /// message. A variable-time comparator that short-circuits on the
135    /// first mismatching byte would leak, via the error text or via
136    /// timing, which byte failed — the classic CWE-208 timing side
137    /// channel. We rely on `hmac::Mac::verify_slice` for constant-time
138    /// comparison; this test locks the observable behavior.
139    #[test]
140    fn mac_tamper_at_any_byte_returns_the_same_error() {
141        let key = [11; 32];
142        let sealed = seal(&key, "domain", 0, &serde_json::json!({"k": "v"})).unwrap();
143        let envelope: serde_json::Value = serde_json::from_slice(&sealed).unwrap();
144        let mac_hex = envelope["mac"].as_str().unwrap().to_owned();
145        let mut errors = std::collections::HashSet::new();
146        // Mutate each hex character in turn (32 bytes of MAC = 64 hex chars).
147        for i in 0..mac_hex.len() {
148            let mut bytes: Vec<u8> = mac_hex.as_bytes().to_vec();
149            // Flip a nibble to something guaranteed different.
150            bytes[i] = if bytes[i] == b'0' { b'f' } else { b'0' };
151            let tampered_hex = String::from_utf8(bytes).unwrap();
152            let mut tampered_envelope = envelope.clone();
153            tampered_envelope["mac"] = serde_json::json!(tampered_hex);
154            let tampered = serde_json::to_vec(&tampered_envelope).unwrap();
155            let err = open::<serde_json::Value>(&key, "domain", 0, &tampered).unwrap_err();
156            errors.insert(err);
157        }
158        assert_eq!(
159            errors.len(),
160            1,
161            "MAC verification must return a single error text regardless of which byte failed \
162             (got {} distinct error texts, i.e. an oracle): {errors:?}",
163            errors.len()
164        );
165    }
166
167    /// Journal `open` rejects an envelope missing the `mac` field with the
168    /// SAME error surface as any other malformed input — the presence or
169    /// absence of the MAC must not distinguish itself from a bad MAC.
170    #[test]
171    fn journal_open_treats_missing_mac_as_malformed_not_as_a_verification_failure() {
172        let sealed = serde_json::json!({"index": 0, "payload": {"k": "v"}}).to_string();
173        let key = [3; 32];
174        let err = open::<serde_json::Value>(&key, "domain", 0, sealed.as_bytes())
175            .expect_err("must reject an envelope missing mac");
176        assert!(
177            !err.contains("authentication"),
178            "missing-mac error {err:?} must not leak MAC-verification status",
179        );
180    }
181
182    /// Round-26 F3: an fs-tamper attacker with read access to the
183    /// journal directory used to be able to probe `expected_index`
184    /// by feeding any envelope with a wrong index and reading both
185    /// values out of the disclosed error text — a position oracle
186    /// for the reconciler's on-disk cursor. Now MAC is verified
187    /// first; a wrong-index envelope with a real MAC returns a
188    /// generic "position mismatch"; a wrong-index envelope with a
189    /// forged MAC returns "authentication failed"; neither reveals
190    /// `envelope.index` or `expected_index`.
191    #[test]
192    fn round_26_f3_index_mismatch_error_does_not_disclose_position() {
193        let key = [17; 32];
194        // Envelope legitimately sealed at index=3.
195        let sealed_at_3 = seal(&key, "domain", 3, &serde_json::json!({"k": "v"})).unwrap();
196        // Caller expects index=99 — the check now fires AFTER MAC verify
197        // succeeds, and the error must not carry either number.
198        let err = open::<serde_json::Value>(&key, "domain", 99, &sealed_at_3).unwrap_err();
199        assert!(
200            !err.contains("3") && !err.contains("99"),
201            "position mismatch error must not disclose either index; got {err:?}"
202        );
203        assert!(
204            !err.contains("authentication"),
205            "mismatch on a genuine envelope must not be labelled an auth failure; got {err:?}"
206        );
207        // A wrong-index envelope with a forged MAC should be labelled
208        // authentication (the MAC check fires first now, so a probe
209        // never reaches the position compare on a forgery).
210        let mut envelope: serde_json::Value = serde_json::from_slice(&sealed_at_3).unwrap();
211        envelope["mac"] = serde_json::json!("00".repeat(32));
212        let forged = serde_json::to_vec(&envelope).unwrap();
213        let err = open::<serde_json::Value>(&key, "domain", 99, &forged).unwrap_err();
214        assert!(
215            err.contains("authentication"),
216            "forged MAC must fail as authentication, not position mismatch; got {err:?}"
217        );
218    }
219}