Skip to main content

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