1use chrono::{DateTime, Utc};
5use ring::hmac;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use pf_core::cas::BlobStore;
10use pf_core::digest::Digest256;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum SideEffectClass {
16 Pure,
18 Idempotent,
20 Irreversible,
22 NetworkOnly,
24}
25
26#[derive(Clone)]
29pub struct SessionSecret(Vec<u8>);
30
31impl SessionSecret {
32 #[must_use]
34 pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
35 Self(bytes.into())
36 }
37
38 pub fn generate() -> pf_core::Result<Self> {
40 use ring::rand::SecureRandom;
41 let mut buf = [0u8; 32];
42 ring::rand::SystemRandom::new()
43 .fill(&mut buf)
44 .map_err(|_| pf_core::Error::Integrity("RNG failed".into()))?;
45 Ok(Self(buf.to_vec()))
46 }
47
48 fn key(&self) -> hmac::Key {
49 hmac::Key::new(hmac::HMAC_SHA256, &self.0)
50 }
51}
52
53impl std::fmt::Debug for SessionSecret {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 write!(f, "SessionSecret(<{} bytes redacted>)", self.0.len())
56 }
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct LedgerEntry {
62 #[serde(rename = "ts")]
64 pub timestamp: DateTime<Utc>,
65 pub tool_id: String,
67 pub args_hash: Digest256,
69 pub idempotency_key: String,
73 pub result_hash: Digest256,
75 pub side_effect_class: SideEffectClass,
77 pub session_hmac: String,
80}
81
82impl LedgerEntry {
83 pub fn entry_hash_without_hmac(&self) -> pf_core::Result<Digest256> {
86 let mut clone = self.clone();
87 clone.session_hmac.clear();
88 let bytes = serde_json::to_vec(&clone)?;
89 Ok(Digest256::of(&bytes))
90 }
91}
92
93#[derive(Clone, Debug)]
97pub struct Ledger {
98 secret: SessionSecret,
99 entries: Vec<LedgerEntry>,
100}
101
102impl Ledger {
103 #[must_use]
105 pub fn new(secret: SessionSecret) -> Self {
106 Self {
107 secret,
108 entries: Vec::new(),
109 }
110 }
111
112 #[must_use]
114 pub fn entries(&self) -> &[LedgerEntry] {
115 &self.entries
116 }
117
118 pub fn append(
121 &mut self,
122 timestamp: DateTime<Utc>,
123 tool_id: impl Into<String>,
124 args_hash: Digest256,
125 idempotency_key: impl Into<String>,
126 result_hash: Digest256,
127 side_effect_class: SideEffectClass,
128 ) -> pf_core::Result<&LedgerEntry> {
129 let mut entry = LedgerEntry {
130 timestamp,
131 tool_id: tool_id.into(),
132 args_hash,
133 idempotency_key: idempotency_key.into(),
134 result_hash,
135 side_effect_class,
136 session_hmac: String::new(),
137 };
138 let prev = self
139 .entries
140 .last()
141 .map(LedgerEntry::entry_hash_without_hmac)
142 .transpose()?
143 .map_or(String::new(), |d| d.hex().to_owned());
144 let this = entry.entry_hash_without_hmac()?;
145 let mut to_sign = Vec::with_capacity(prev.len() + this.hex().len());
146 to_sign.extend_from_slice(prev.as_bytes());
147 to_sign.extend_from_slice(this.hex().as_bytes());
148 let tag = hmac::sign(&self.secret.key(), &to_sign);
149 entry.session_hmac = hex::encode(tag.as_ref());
150 self.entries.push(entry);
151 Ok(self.entries.last().unwrap())
152 }
153
154 pub fn verify(&self) -> pf_core::Result<()> {
157 let mut prev_hash = String::new();
158 for (ix, e) in self.entries.iter().enumerate() {
159 let this = e.entry_hash_without_hmac()?;
160 let mut to_sign = Vec::with_capacity(prev_hash.len() + this.hex().len());
161 to_sign.extend_from_slice(prev_hash.as_bytes());
162 to_sign.extend_from_slice(this.hex().as_bytes());
163 let expected_tag = hex::decode(&e.session_hmac)
164 .map_err(|err| pf_core::Error::Integrity(format!("entry {ix}: bad hex: {err}")))?;
165 if hmac::verify(&self.secret.key(), &to_sign, &expected_tag).is_err() {
166 return Err(pf_core::Error::Integrity(format!(
167 "ledger HMAC mismatch at entry index {ix}"
168 )));
169 }
170 this.hex().clone_into(&mut prev_hash);
171 }
172 Ok(())
173 }
174
175 pub fn serialize(&self, blobs: &dyn BlobStore) -> pf_core::Result<Digest256> {
179 let mut out = Vec::new();
180 for e in &self.entries {
181 out.extend_from_slice(&serde_json::to_vec(e)?);
182 out.push(b'\n');
183 }
184 let mut blob = Vec::with_capacity(out.len() + 64);
186 let header =
187 serde_json::json!({"kind": "effects.ledger.v1", "entries": self.entries.len()});
188 blob.extend_from_slice(&serde_json::to_vec(&header)?);
189 blob.push(b'\n');
190 blob.extend_from_slice(&out);
191 blobs.put(&blob)
192 }
193
194 pub fn deserialize(
198 blobs: &dyn BlobStore,
199 digest: &Digest256,
200 secret: SessionSecret,
201 ) -> pf_core::Result<Self> {
202 let bytes = blobs.get(digest)?;
203 let mut lines = bytes.split(|b| *b == b'\n').filter(|l| !l.is_empty());
204 let header = lines
205 .next()
206 .ok_or_else(|| pf_core::Error::Integrity("ledger blob has no header line".into()))?;
207 let header_v: serde_json::Value = serde_json::from_slice(header)?;
208 if header_v.get("kind").and_then(|v| v.as_str()) != Some("effects.ledger.v1") {
209 return Err(pf_core::Error::Integrity(
210 "not an effects.ledger.v1 blob".into(),
211 ));
212 }
213 let mut entries = Vec::new();
214 for line in lines {
215 entries.push(serde_json::from_slice::<LedgerEntry>(line)?);
216 }
217 Ok(Self { secret, entries })
218 }
219}
220
221pub fn args_hash(args: &impl Serialize) -> pf_core::Result<Digest256> {
224 Ok(Digest256::of(&serde_json::to_vec(args)?))
225}
226
227pub fn mint_idempotency_key() -> pf_core::Result<String> {
230 use ring::rand::SecureRandom;
231 let mut rand = [0u8; 10];
232 ring::rand::SystemRandom::new()
233 .fill(&mut rand)
234 .map_err(|_| pf_core::Error::Integrity("RNG failed".into()))?;
235 let ts_ms = u64::try_from(Utc::now().timestamp_millis()).unwrap_or(0);
236 let mut hasher = Sha256::new();
237 hasher.update(ts_ms.to_be_bytes());
238 hasher.update(rand);
239 let h = hasher.finalize();
240 Ok(format!(
242 "01J{:013}{}",
243 ts_ms % 10_000_000_000_000,
244 hex::encode(&h[..5])
245 ))
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use pf_core::cas::MemBlobStore;
252
253 fn empty_ledger() -> Ledger {
254 Ledger::new(SessionSecret::new(b"unit-test-secret".to_vec()))
255 }
256
257 fn fake_digest(byte: u8) -> Digest256 {
258 Digest256::of(&[byte; 32])
259 }
260
261 #[test]
262 fn appended_entry_has_hmac() {
263 let mut l = empty_ledger();
264 let e = l
265 .append(
266 Utc::now(),
267 "send_email",
268 fake_digest(1),
269 "01JTEST".to_owned(),
270 fake_digest(2),
271 SideEffectClass::Irreversible,
272 )
273 .unwrap();
274 assert!(!e.session_hmac.is_empty());
275 assert_eq!(e.tool_id, "send_email");
276 }
277
278 #[test]
279 fn verify_succeeds_on_clean_chain() {
280 let mut l = empty_ledger();
281 for i in 0..16u8 {
282 l.append(
283 Utc::now(),
284 format!("tool_{i}"),
285 fake_digest(i),
286 format!("k{i}"),
287 fake_digest(i ^ 0x55),
288 if i % 5 == 0 {
289 SideEffectClass::Irreversible
290 } else {
291 SideEffectClass::Pure
292 },
293 )
294 .unwrap();
295 }
296 l.verify().unwrap();
297 }
298
299 #[test]
300 fn verify_detects_tampering() {
301 let mut l = empty_ledger();
302 l.append(
303 Utc::now(),
304 "a",
305 fake_digest(0),
306 "k0",
307 fake_digest(0),
308 SideEffectClass::Pure,
309 )
310 .unwrap();
311 l.append(
312 Utc::now(),
313 "b",
314 fake_digest(1),
315 "k1",
316 fake_digest(1),
317 SideEffectClass::Pure,
318 )
319 .unwrap();
320 l.entries[0].tool_id = "evil".into();
322 assert!(l.verify().is_err());
323 }
324
325 #[test]
326 fn round_trip_through_blob_store() {
327 let blobs = MemBlobStore::new();
328 let secret = SessionSecret::new(b"round-trip-secret".to_vec());
329 let mut l = Ledger::new(secret.clone());
330 for i in 0..4u8 {
331 l.append(
332 Utc::now(),
333 format!("t{i}"),
334 fake_digest(i),
335 format!("k{i}"),
336 fake_digest(i),
337 SideEffectClass::Idempotent,
338 )
339 .unwrap();
340 }
341 let cid = l.serialize(&blobs).unwrap();
342 let back = Ledger::deserialize(&blobs, &cid, secret).unwrap();
343 assert_eq!(back.entries().len(), 4);
344 back.verify().unwrap();
345 }
346
347 #[test]
348 fn wrong_secret_fails_verification() {
349 let blobs = MemBlobStore::new();
350 let mut l = Ledger::new(SessionSecret::new(b"good".to_vec()));
351 l.append(
352 Utc::now(),
353 "t",
354 fake_digest(0),
355 "k",
356 fake_digest(1),
357 SideEffectClass::Pure,
358 )
359 .unwrap();
360 let cid = l.serialize(&blobs).unwrap();
361 let back = Ledger::deserialize(&blobs, &cid, SessionSecret::new(b"evil".to_vec())).unwrap();
362 assert!(back.verify().is_err());
363 }
364
365 #[test]
366 fn idempotency_key_unique_within_loop() {
367 let mut seen = std::collections::HashSet::new();
368 for _ in 0..256 {
369 let k = mint_idempotency_key().unwrap();
370 assert!(seen.insert(k));
371 }
372 }
373
374 #[test]
375 fn secret_debug_does_not_leak() {
376 let s = SessionSecret::new(b"shhh".to_vec());
377 let dbg = format!("{s:?}");
378 assert!(!dbg.contains("shhh"));
379 assert!(dbg.contains("redacted"));
380 }
381}