Skip to main content

av_harness/
journal.rs

1//! Authenticated envelopes for active-workflow crash journals.
2
3use hmac::{Hmac, KeyInit as _, 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 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";
21pub const SESSION_QUARANTINE_OUTBOX_KIND: &str = "session-quarantine";
22
23#[derive(Serialize, Deserialize)]
24struct Envelope {
25    index: u64,
26    payload: serde_json::Value,
27    mac: String,
28}
29
30/// Derive the deployment-local HMAC key used for authenticated control files.
31pub fn key_from_signer(signer: &dyn av_receipts::Signer) -> [u8; 32] {
32    let signature = signer.sign(b"agentvisor-active-journal-key-v1");
33    let mut key = [0u8; 32];
34    key.copy_from_slice(&signature[..32]);
35    key
36}
37
38pub(crate) fn seal<T: Serialize>(
39    key: &[u8; 32],
40    domain: &str,
41    index: u64,
42    value: &T,
43) -> Result<Vec<u8>, String> {
44    let payload = serde_json::to_value(value).map_err(|error| error.to_string())?;
45    let mac = build_mac(key, domain, index, &payload)?.finalize().into_bytes();
46    serde_json::to_vec(&Envelope {
47        index,
48        payload,
49        mac: hex::encode(mac),
50    })
51    .map_err(|error| error.to_string())
52}
53
54pub(crate) fn open<T: DeserializeOwned>(
55    key: &[u8; 32],
56    domain: &str,
57    expected_index: u64,
58    bytes: &[u8],
59) -> Result<T, String> {
60    let envelope: Envelope = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
61    // HMAC-SHA256 renders as exactly 64 hex chars. A
62    // fs-tamper attacker with a multi-MB mac field would otherwise
63    // force `hex::decode` to allocate half the string length on
64    // every recovery-scan tick. Cap at 128 (twice the legitimate
65    // length to allow one round of format experimentation).
66    if envelope.mac.len() > 128 {
67        return Err(format!(
68            "journal mac field is {} chars; refusing (HMAC-SHA256 is 64 hex chars)",
69            envelope.mac.len()
70        ));
71    }
72    let claimed = hex::decode(&envelope.mac).map_err(|error| error.to_string())?;
73    // Verify the MAC BEFORE any index-mismatch branch.
74    // The old order (index check first, MAC last) meant that an
75    // fs-tamper attacker with read access to the journal directory
76    // could probe `expected_index` for every position and learn the
77    // reconciler's on-disk cursor via the disclosed `envelope.index`
78    // and `expected_index` in the error text. Not a forgery hole —
79    // the MAC still guards authenticity — but it's a position
80    // oracle that lets an adversary map the state machine and craft
81    // targeted quarantine denial-of-restore attacks. Verify first,
82    // then compare positions with a generic error.
83    let verifier = build_mac(key, domain, envelope.index, &envelope.payload)?;
84    verifier
85        .verify_slice(&claimed)
86        .map_err(|_| "journal authentication failed".to_owned())?;
87    if envelope.index != expected_index {
88        return Err("journal position mismatch".to_owned());
89    }
90    serde_json::from_value(envelope.payload).map_err(|error| error.to_string())
91}
92
93fn build_mac(
94    key: &[u8; 32],
95    domain: &str,
96    index: u64,
97    payload: &serde_json::Value,
98) -> Result<HmacSha256, String> {
99    let canonical = av_receipts::canonicalize(payload).map_err(|error| error.to_string())?;
100    let mut mac = HmacSha256::new_from_slice(key).map_err(|error| error.to_string())?;
101    mac.update(b"agentvisor-journal-v1\0");
102    mac.update(&(domain.len() as u64).to_be_bytes());
103    mac.update(domain.as_bytes());
104    mac.update(&index.to_be_bytes());
105    mac.update(&(canonical.len() as u64).to_be_bytes());
106    mac.update(canonical.as_bytes());
107    Ok(mac)
108}
109
110#[cfg(test)]
111mod tests {
112    #![allow(clippy::expect_used, clippy::indexing_slicing, clippy::unwrap_used)]
113
114    use super::*;
115
116    /// The journal HMAC key must be a per-deployment derivation from
117    /// the signer — a constant key would give every deployment one
118    /// shared forgery key, and NO round-trip test can catch that
119    /// (seal and open would agree on the wrong constant). Pin the
120    /// uniqueness property directly: distinct signers derive distinct
121    /// keys, the same signer derives the same key, and the key is
122    /// never a degenerate constant.
123    #[test]
124    fn journal_key_is_derived_per_signer() {
125        let a = av_receipts::Ed25519Signer::from_seed(&[3; 32]);
126        let b = av_receipts::Ed25519Signer::from_seed(&[4; 32]);
127        let key_a = key_from_signer(&a);
128        let key_b = key_from_signer(&b);
129        assert_ne!(key_a, key_b, "distinct signers must derive distinct journal keys");
130        assert_eq!(key_a, key_from_signer(&a), "derivation must be deterministic");
131        assert_ne!(key_a, [0u8; 32]);
132        assert_ne!(key_a, [1u8; 32]);
133        assert_ne!(key_b, [0u8; 32]);
134        assert_ne!(key_b, [1u8; 32]);
135    }
136
137    /// Mutation-run hardening (round 9): the mac-length DoS cap was
138    /// unpinned — a `>`→`==` mutant admitted a multi-MB `mac` field
139    /// into `hex::decode`, re-opening the allocation-per-tick attack
140    /// the cap exists to close. Pin both sides of the exact boundary:
141    /// 128 hex chars still reaches MAC verification (rejected as a
142    /// bad MAC, not as oversize), 129 is refused up front with the
143    /// oversize message.
144    #[test]
145    fn mac_length_cap_boundary_is_exact() {
146        let key = [7u8; 32];
147        let at_cap = serde_json::to_vec(&Envelope {
148            index: 0,
149            payload: serde_json::json!({"x": 1}),
150            mac: "ab".repeat(64), // 128 chars: allowed through to verification
151        })
152        .unwrap();
153        let error = open::<serde_json::Value>(&key, "test-domain", 0, &at_cap).unwrap_err();
154        assert!(
155            !error.contains("refusing (HMAC-SHA256"),
156            "128 chars must pass the size gate (and fail MAC verification instead): {error}"
157        );
158        let past_cap = serde_json::to_vec(&Envelope {
159            index: 0,
160            payload: serde_json::json!({"x": 1}),
161            mac: "a".repeat(129),
162        })
163        .unwrap();
164        let error = open::<serde_json::Value>(&key, "test-domain", 0, &past_cap).unwrap_err();
165        assert!(
166            error.contains("refusing (HMAC-SHA256"),
167            "129 chars must be refused by the size gate before any decode: {error}"
168        );
169        // Far past the cap (the actual attack shape): still the size
170        // gate, never an allocation-heavy decode.
171        let huge = serde_json::to_vec(&Envelope {
172            index: 0,
173            payload: serde_json::json!({"x": 1}),
174            mac: "a".repeat(1024 * 1024),
175        })
176        .unwrap();
177        let error = open::<serde_json::Value>(&key, "test-domain", 0, &huge).unwrap_err();
178        assert!(error.contains("refusing (HMAC-SHA256"), "{error}");
179    }
180
181    /// The mac field length cap (128 chars) refuses oversized values
182    /// before hex-decode allocates; exactly 128 still verifies the MAC
183    /// path (and fails authentication, since it's not a real MAC).
184    #[test]
185    fn oversized_mac_field_is_refused_at_the_cap() {
186        let key = [7; 32];
187        let sealed = seal(&key, "session:signed", 0, &serde_json::json!({"v": 1})).unwrap();
188        let mut envelope: serde_json::Value = serde_json::from_slice(&sealed).unwrap();
189        // Use an EVEN oversized length so hex::decode wouldn't refuse
190        // it for the wrong reason (odd length). The previous 129-char
191        // fixture masked a regression that deleted the > 128 cap
192        // entirely: hex::decode caught it on the parity check, so
193        // is_err() still passed even though the DoS-prevention cap
194        // that gives this test its name was gone.
195        envelope["mac"] = serde_json::json!("a".repeat(4096));
196        let oversized = serde_json::to_vec(&envelope).unwrap();
197        let err = open::<serde_json::Value>(&key, "session:signed", 0, &oversized)
198            .expect_err("multi-KiB mac field must be refused before hex::decode allocates");
199        assert!(
200            err.contains("64 hex chars"),
201            "the cap this test names (mac field length before hex decode) must be what \
202             refuses the value — got {err:?}"
203        );
204        envelope["mac"] = serde_json::json!("a".repeat(128));
205        let at_cap = serde_json::to_vec(&envelope).unwrap();
206        assert!(open::<serde_json::Value>(&key, "session:signed", 0, &at_cap).is_err());
207    }
208
209    #[test]
210    fn mutation_and_reordering_fail_authentication() {
211        let key = [7; 32];
212        let sealed = seal(&key, "session:signed", 3, &serde_json::json!({"value": 1})).unwrap();
213        let value: serde_json::Value = open(&key, "session:signed", 3, &sealed).unwrap();
214        assert_eq!(value["value"], 1);
215        assert!(open::<serde_json::Value>(&key, "session:signed", 2, &sealed).is_err());
216        let mut envelope: serde_json::Value = serde_json::from_slice(&sealed).unwrap();
217        envelope["payload"]["value"] = serde_json::json!(2);
218        assert!(open::<serde_json::Value>(
219            &key,
220            "session:signed",
221            3,
222            &serde_json::to_vec(&envelope).unwrap()
223        )
224        .is_err());
225    }
226
227    /// Every MAC byte mutated in isolation must produce the SAME error
228    /// message. A variable-time comparator that short-circuits on the
229    /// first mismatching byte would leak, via the error text or via
230    /// timing, which byte failed — the classic CWE-208 timing side
231    /// channel. We rely on `hmac::Mac::verify_slice` for constant-time
232    /// comparison; this test locks the observable behavior.
233    #[test]
234    fn mac_tamper_at_any_byte_returns_the_same_error() {
235        let key = [11; 32];
236        let sealed = seal(&key, "domain", 0, &serde_json::json!({"k": "v"})).unwrap();
237        let envelope: serde_json::Value = serde_json::from_slice(&sealed).unwrap();
238        let mac_hex = envelope["mac"].as_str().unwrap().to_owned();
239        let mut errors = std::collections::HashSet::new();
240        // Mutate each hex character in turn (32 bytes of MAC = 64 hex chars).
241        for i in 0..mac_hex.len() {
242            let mut bytes: Vec<u8> = mac_hex.as_bytes().to_vec();
243            // Flip a nibble to something guaranteed different.
244            bytes[i] = if bytes[i] == b'0' { b'f' } else { b'0' };
245            let tampered_hex = String::from_utf8(bytes).unwrap();
246            let mut tampered_envelope = envelope.clone();
247            tampered_envelope["mac"] = serde_json::json!(tampered_hex);
248            let tampered = serde_json::to_vec(&tampered_envelope).unwrap();
249            let err = open::<serde_json::Value>(&key, "domain", 0, &tampered).unwrap_err();
250            errors.insert(err);
251        }
252        assert_eq!(
253            errors.len(),
254            1,
255            "MAC verification must return a single error text regardless of which byte failed \
256             (got {} distinct error texts, i.e. an oracle): {errors:?}",
257            errors.len()
258        );
259    }
260
261    /// Journal `open` rejects an envelope missing the `mac` field with the
262    /// SAME error surface as any other malformed input — the presence or
263    /// absence of the MAC must not distinguish itself from a bad MAC.
264    #[test]
265    fn journal_open_treats_missing_mac_as_malformed_not_as_a_verification_failure() {
266        let sealed = serde_json::json!({"index": 0, "payload": {"k": "v"}}).to_string();
267        let key = [3; 32];
268        let err = open::<serde_json::Value>(&key, "domain", 0, sealed.as_bytes())
269            .expect_err("must reject an envelope missing mac");
270        assert!(
271            !err.contains("authentication"),
272            "missing-mac error {err:?} must not leak MAC-verification status",
273        );
274    }
275
276    /// An fs-tamper attacker with read access to the
277    /// journal directory used to be able to probe `expected_index`
278    /// by feeding any envelope with a wrong index and reading both
279    /// values out of the disclosed error text — a position oracle
280    /// for the reconciler's on-disk cursor. Now MAC is verified
281    /// first; a wrong-index envelope with a real MAC returns a
282    /// generic "position mismatch"; a wrong-index envelope with a
283    /// forged MAC returns "authentication failed"; neither reveals
284    /// `envelope.index` or `expected_index`.
285    #[test]
286    fn round_26_f3_index_mismatch_error_does_not_disclose_position() {
287        let key = [17; 32];
288        // Envelope legitimately sealed at index=3.
289        let sealed_at_3 = seal(&key, "domain", 3, &serde_json::json!({"k": "v"})).unwrap();
290        // Caller expects index=99 — the check now fires AFTER MAC verify
291        // succeeds, and the error must not carry either number.
292        let err = open::<serde_json::Value>(&key, "domain", 99, &sealed_at_3).unwrap_err();
293        assert!(
294            !err.contains("3") && !err.contains("99"),
295            "position mismatch error must not disclose either index; got {err:?}"
296        );
297        assert!(
298            !err.contains("authentication"),
299            "mismatch on a genuine envelope must not be labelled an auth failure; got {err:?}"
300        );
301        // A wrong-index envelope with a forged MAC should be labelled
302        // authentication (the MAC check fires first now, so a probe
303        // never reaches the position compare on a forgery).
304        let mut envelope: serde_json::Value = serde_json::from_slice(&sealed_at_3).unwrap();
305        envelope["mac"] = serde_json::json!("00".repeat(32));
306        let forged = serde_json::to_vec(&envelope).unwrap();
307        let err = open::<serde_json::Value>(&key, "domain", 99, &forged).unwrap_err();
308        assert!(
309            err.contains("authentication"),
310            "forged MAC must fail as authentication, not position mismatch; got {err:?}"
311        );
312    }
313}