Skip to main content

pf_effects/
ledger.rs

1// SPDX-License-Identifier: MIT
2//! Append-only effect ledger with HMAC chaining.
3
4use 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/// How "dangerous" a tool call's side-effect is, for replay policy purposes.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum SideEffectClass {
16    /// Pure function — safe to replay from cached result.
17    Pure,
18    /// Idempotent under the same `idempotency_key` (POST-with-key, PUT, …).
19    Idempotent,
20    /// Genuinely irreversible (sent email, charged card, dropped table).
21    Irreversible,
22    /// Network-only read (e.g. GET) — cacheable but stale-aware.
23    NetworkOnly,
24}
25
26/// Per-session HMAC key. Wraps an opaque byte slice; debug-prints as
27/// `SessionSecret(<redacted>)` so it never accidentally lands in logs.
28#[derive(Clone)]
29pub struct SessionSecret(Vec<u8>);
30
31impl SessionSecret {
32    /// Wrap an existing byte slice (e.g. from a hardware HSM or env var).
33    #[must_use]
34    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
35        Self(bytes.into())
36    }
37
38    /// Generate a fresh 32-byte secret using `ring::rand`.
39    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/// A single ledger entry. Wire format `effects.entry.v1`.
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct LedgerEntry {
62    /// Wall-clock timestamp at the moment the tool call was issued.
63    #[serde(rename = "ts")]
64    pub timestamp: DateTime<Utc>,
65    /// Tool identifier as registered with the [`crate::ToolProxy`].
66    pub tool_id: String,
67    /// SHA-256 of the canonical-JSON-serialized tool args.
68    pub args_hash: Digest256,
69    /// Per-call idempotency key. ULID-shaped for sortability; the proxy mints
70    /// these and persists them so a re-issued call after restore reuses the
71    /// key and is therefore safe under `Idempotent` semantics.
72    pub idempotency_key: String,
73    /// SHA-256 of the canonical-JSON-serialized tool result.
74    pub result_hash: Digest256,
75    /// Tool author's declared side-effect class.
76    pub side_effect_class: SideEffectClass,
77    /// HMAC chain: `HMAC(session_secret, prev_entry_hash || this_entry_minus_hmac)`.
78    /// Hex-encoded.
79    pub session_hmac: String,
80}
81
82impl LedgerEntry {
83    /// SHA-256 of the canonical-JSON serialization of this entry **with
84    /// `session_hmac = ""`**. Used both for chaining and for storage CAS.
85    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/// An append-only ledger held in memory. Persistent storage is the
94/// caller's responsibility: call [`Ledger::serialize`] to get a CAS-ready
95/// blob, [`Ledger::deserialize`] to restore.
96#[derive(Clone, Debug)]
97pub struct Ledger {
98    secret: SessionSecret,
99    entries: Vec<LedgerEntry>,
100}
101
102impl Ledger {
103    /// Open a fresh ledger with the given session secret.
104    #[must_use]
105    pub fn new(secret: SessionSecret) -> Self {
106        Self {
107            secret,
108            entries: Vec::new(),
109        }
110    }
111
112    /// Borrow the ledger entries in causal order.
113    #[must_use]
114    pub fn entries(&self) -> &[LedgerEntry] {
115        &self.entries
116    }
117
118    /// Append a new entry and chain its HMAC. The caller supplies everything
119    /// except `session_hmac`, which we compute here.
120    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    /// Verify the entire HMAC chain. Returns `Ok(())` if every entry verifies
155    /// in order; otherwise the first bad-entry index in `Err`.
156    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    /// Serialize the ledger to a single JSONL blob and store via `blobs`.
176    /// Note: the secret is NOT serialized — it must be re-supplied at
177    /// deserialization time.
178    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        // Prepend a header line for self-description.
185        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    /// Restore a ledger from a previously-stored blob. The caller must supply
195    /// the same `secret` that signed the chain; otherwise [`Self::verify`]
196    /// will fail.
197    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
221/// Hash a serializable value via canonical-ish JSON (good enough — we depend
222/// on `serde_json`'s stable field ordering + our types having no maps).
223pub fn args_hash(args: &impl Serialize) -> pf_core::Result<Digest256> {
224    Ok(Digest256::of(&serde_json::to_vec(args)?))
225}
226
227/// Mint a sortable idempotency key by combining a Unix timestamp prefix with
228/// 80 bits of randomness, ULID-style.
229pub 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    // 26-char base32-like string for ULID compatibility on length only.
241    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        // Tamper with entry 0 *after* signing — chain must fail.
321        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}