1use hmac::{Hmac, KeyInit as _, Mac as _};
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use sha2::Sha256;
7
8type HmacSha256 = Hmac<Sha256>;
9
10pub 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
17pub 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
30pub fn key_from_signer(signer: &dyn pv_receipts::Signer) -> [u8; 32] {
32 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 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 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 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 #[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 #[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), })
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 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 #[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 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 #[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 for i in 0..mac_hex.len() {
244 let mut bytes: Vec<u8> = mac_hex.as_bytes().to_vec();
245 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 #[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 #[test]
288 fn round_26_f3_index_mismatch_error_does_not_disclose_position() {
289 let key = [17; 32];
290 let sealed_at_3 = seal(&key, "domain", 3, &serde_json::json!({"k": "v"})).unwrap();
292 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 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}