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 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 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 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 #[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 #[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), })
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 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 #[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 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 #[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 for i in 0..mac_hex.len() {
242 let mut bytes: Vec<u8> = mac_hex.as_bytes().to_vec();
243 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 #[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 #[test]
286 fn round_26_f3_index_mismatch_error_does_not_disclose_position() {
287 let key = [17; 32];
288 let sealed_at_3 = seal(&key, "domain", 3, &serde_json::json!({"k": "v"})).unwrap();
290 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 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}