1use hmac::{Hmac, 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";
21
22#[derive(Serialize, Deserialize)]
23struct Envelope {
24 index: u64,
25 payload: serde_json::Value,
26 mac: String,
27}
28
29pub 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 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 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 #[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 for i in 0..mac_hex.len() {
148 let mut bytes: Vec<u8> = mac_hex.as_bytes().to_vec();
149 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 #[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 #[test]
192 fn round_26_f3_index_mismatch_error_does_not_disclose_position() {
193 let key = [17; 32];
194 let sealed_at_3 = seal(&key, "domain", 3, &serde_json::json!({"k": "v"})).unwrap();
196 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 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}