Skip to main content

polyc_eventlog_model/
integrity.rs

1//! Per-conversation tamper-evidence over the event log (#799).
2//!
3//! The partition journal gives durability and ordering but — on its own — no
4//! way for a reader to tell that an operator (or a storage-layer bit flip)
5//! hasn't quietly rewritten a persisted event between when it was appended
6//! and when it was replayed. This module closes that gap by threading a
7//! [`polyc_mmr::VerifiableLog`] alongside the journal:
8//!
9//! 1. Every conversation event becomes one MMR leaf (see
10//!    [`rebuild_from_events`] for how a cold log's tree is reconstructed).
11//! 2. After extending the tree with a batch of new events, the caller signs
12//!    the current root and appends it to the SAME partition as one more
13//!    event ([`MMR_SIGNED_ROOT_KIND`]) — see [`extend_and_sign`]. Because it
14//!    lands in the same commit as the turn's other events, the root is
15//!    signed atomically with the content it covers.
16//! 3. A later reader replays the whole partition and calls [`verify_replay`],
17//!    which rebuilds the tree from scratch and checks every signed root it
18//!    finds along the way — catching a tampered event, a forged or
19//!    substituted root marker, or a root signed under the wrong key.
20//!
21//! This is intentionally decoupled from the journal itself: everything here
22//! operates on plain `(kind, payload)` / [`Event`] sequences, so it is
23//! testable without a live journal and reusable by anything that replays a
24//! partition (the eventlog host, the CLI's `conversation repair`, forensics).
25
26// Several doc summaries here need two sentences to state both the "what"
27// and the atomicity/ordering contract in one place, rather than splitting
28// across a line an editor of this module would have to re-join to reread.
29#![allow(clippy::too_long_first_doc_paragraph)]
30
31use polyc_crypto::signing_role::JournalAttestationSigner;
32use polyc_mmr::{SignedRoot, VerifiableLog, verify_root_signature_with_trust};
33
34use crate::Event;
35
36/// Event kind naming a persisted [`SignedRoot`] marker. Namespaced so it
37/// cannot collide with any conversation-content kind (every real kind in
38/// `polyc-proto`'s `events.proto` is a bare identifier with no `__`
39/// wrapping).
40pub const MMR_SIGNED_ROOT_KIND: &str = "__mmr_signed_root__";
41
42/// Failures from extending, signing, or verifying a partition's MMR.
43#[derive(Debug, thiserror::Error)]
44#[non_exhaustive]
45pub enum IntegrityError {
46    /// The underlying MMR operation failed (lock poisoned, proof error).
47    #[error("mmr: {0}")]
48    Mmr(#[from] polyc_mmr::MmrError),
49    /// A signed-root marker's payload was not the JSON [`SignedRoot`] this
50    /// module writes — a corrupted or foreign event landed under the
51    /// reserved kind.
52    #[error("malformed signed-root marker at leaf count {leaf_count_hint}: {source}")]
53    MalformedRoot {
54        /// Running leaf count at the point the malformed marker was found,
55        /// for locating it in the replay.
56        leaf_count_hint: u64,
57        /// The JSON decode error.
58        source: serde_json::Error,
59    },
60    /// A persisted root's ed25519 signature does not verify under the
61    /// expected signer public key — the marker was forged, or signed by a
62    /// different key than the caller trusts.
63    #[error("signed root at leaf count {leaf_count} does not verify under the expected signer")]
64    SignatureInvalid {
65        /// The root's claimed leaf count.
66        leaf_count: u64,
67    },
68    /// The recomputed MMR root (or leaf count) at a checkpoint does not
69    /// match what was signed — the tamper-evidence violation this whole
70    /// module exists to catch.
71    #[error(
72        "integrity violation: at leaf count {leaf_count}, replay computed root {computed_root_hex} \
73         but the signed marker recorded {expected_root_hex}"
74    )]
75    RootMismatch {
76        /// Leaf count at the point of the mismatch.
77        leaf_count: u64,
78        /// The root the signed marker claims.
79        expected_root_hex: String,
80        /// The root replay actually computed.
81        computed_root_hex: String,
82    },
83}
84
85/// Extend `log` with each of `new_events`'s `(kind, payload)` leaves, sign
86/// the resulting root with `signer`, and return the [`Event`] to append
87/// (kind [`MMR_SIGNED_ROOT_KIND`]) — the caller places it in the SAME
88/// journal batch as `new_events` (e.g. right before `turn_complete`) so the
89/// signature is atomic with the content it covers.
90///
91/// # Errors
92///
93/// Returns [`IntegrityError::Mmr`] if extending the tree or signing fails.
94///
95/// # Panics
96///
97/// Never in practice: [`SignedRoot`] always serializes (plain strings and
98/// integers), so the internal `expect` cannot fail for any value this
99/// module produces.
100pub fn extend_and_sign(
101    log: &VerifiableLog,
102    new_events: &[Event],
103    signer: &JournalAttestationSigner,
104) -> Result<Event, IntegrityError> {
105    for event in new_events {
106        log.append(&event.kind, &event.payload)?;
107    }
108    let root = log.sign_root(signer)?;
109    let payload = serde_json::to_vec(&root).expect("SignedRoot serializes");
110    Ok(Event::new(MMR_SIGNED_ROOT_KIND, payload))
111}
112
113/// Reconstruct a partition's running MMR from a full replay, for a caller
114/// that wants to keep extending it (the eventlog host, on first touching a
115/// partition after a restart). Root-marker events themselves are not MMR
116/// leaves — only real conversation events are — so this filters them out
117/// before delegating to [`VerifiableLog::rebuild`].
118///
119/// # Errors
120///
121/// Returns [`IntegrityError::Mmr`] if the rebuild fails.
122pub fn rebuild_from_events(events: &[Event]) -> Result<VerifiableLog, IntegrityError> {
123    let log = VerifiableLog::rebuild(
124        events
125            .iter()
126            .filter(|e| e.kind != MMR_SIGNED_ROOT_KIND)
127            .map(|e| (e.kind.as_str(), e.payload.as_slice())),
128    )?;
129    Ok(log)
130}
131
132/// Verify a full partition replay's tamper-evidence: rebuild the MMR leaf by
133/// leaf in append order, and at every [`MMR_SIGNED_ROOT_KIND`] marker check
134/// that (a) its signature verifies under `expected_signer_pk_hex` and (b)
135/// the tree's root and leaf count at that point match what the marker
136/// claims. Returns on the FIRST violation found, naming exactly where it
137/// occurred.
138///
139/// A partition with no signed-root markers at all verifies trivially — this
140/// is the transitional state before the first turn completes.
141///
142/// # Errors
143///
144/// Returns [`IntegrityError`] describing the first tamper/forgery/mismatch
145/// encountered.
146pub fn verify_replay(events: &[Event], expected_signer_pk_hex: &str) -> Result<(), IntegrityError> {
147    let public_key = hex::decode(expected_signer_pk_hex)
148        .map_err(|_| IntegrityError::SignatureInvalid { leaf_count: 0 })?;
149    let trust = polyc_crypto::signing_role::RoleTrustSet::<
150        polyc_crypto::signing_role::JournalAttestationRole,
151    >::from_public_keys(vec![public_key])
152    .map_err(|_| IntegrityError::SignatureInvalid { leaf_count: 0 })?;
153    verify_replay_with_trust(events, &trust)
154}
155
156/// Replays and verifies every root against current and retired role keys.
157///
158/// # Errors
159///
160/// Returns the first malformed, untrusted, or inconsistent root.
161pub fn verify_replay_with_trust(
162    events: &[Event],
163    trust: &polyc_crypto::signing_role::RoleTrustSet<
164        polyc_crypto::signing_role::JournalAttestationRole,
165    >,
166) -> Result<(), IntegrityError> {
167    verify_extension_with_trust(&VerifiableLog::new(), events, trust)
168}
169
170/// Verifies `events` as the CONTINUATION of the partition `log` already covers,
171/// extending `log` leaf by leaf exactly as a replay of the whole partition
172/// would, and checking every [`MMR_SIGNED_ROOT_KIND`] marker it meets against
173/// the tree at that point.
174///
175/// This is [`verify_replay_with_trust`] with its starting tree supplied rather
176/// than empty, which is what a caller holding a partition's running tree needs:
177/// verifying the tail it just appended costs the tail, not the whole partition,
178/// and the check it applies to that tail is the same one a full replay applies.
179/// A caller with no tree passes a fresh [`VerifiableLog`] and gets the full
180/// replay back, which is exactly what [`verify_replay_with_trust`] does.
181///
182/// `log` is extended in place by every non-marker event, including on the way
183/// to an error: a caller that gets an error back holds a tree it must discard
184/// rather than keep extending.
185///
186/// # Errors
187///
188/// Returns [`IntegrityError`] describing the first tamper/forgery/mismatch
189/// encountered, in the same vocabulary a full replay reports it in.
190pub fn verify_extension_with_trust(
191    log: &VerifiableLog,
192    events: &[Event],
193    trust: &polyc_crypto::signing_role::RoleTrustSet<
194        polyc_crypto::signing_role::JournalAttestationRole,
195    >,
196) -> Result<(), IntegrityError> {
197    for event in events {
198        if event.kind == MMR_SIGNED_ROOT_KIND {
199            let leaf_count = log.leaf_count()?;
200            let root: SignedRoot = serde_json::from_slice(&event.payload).map_err(|source| {
201                IntegrityError::MalformedRoot {
202                    leaf_count_hint: leaf_count,
203                    source,
204                }
205            })?;
206            let sig_ok = verify_root_signature_with_trust(&root, trust).unwrap_or(false);
207            if !sig_ok {
208                return Err(IntegrityError::SignatureInvalid { leaf_count });
209            }
210            let computed_root = log.root()?;
211            let computed_root_hex = hex::encode(computed_root.as_ref());
212            if root.leaf_count != leaf_count || root.root_hex != computed_root_hex {
213                return Err(IntegrityError::RootMismatch {
214                    leaf_count,
215                    expected_root_hex: root.root_hex,
216                    computed_root_hex,
217                });
218            }
219        } else {
220            log.append(&event.kind, &event.payload)?;
221        }
222    }
223    Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
229    use super::*;
230
231    fn signer() -> JournalAttestationSigner {
232        JournalAttestationSigner::from_seed(7)
233    }
234
235    fn pk_hex(signer: &JournalAttestationSigner) -> String {
236        hex::encode(signer.public_key_bytes())
237    }
238
239    /// The pinning test (#799): append a turn's worth of events, sign the
240    /// root, replay, flip one byte of one persisted event's payload, and
241    /// assert replay reports an integrity violation. Before this module
242    /// existed nothing checked this at all — `verify_replay` didn't exist.
243    #[test]
244    fn mmr_verify_replay_detects_tampered_event() {
245        let log = VerifiableLog::new();
246        let s = signer();
247        let turn_events = vec![
248            Event::new("user_msg", b"what is 2+2?".to_vec()),
249            Event::new("output_msg", b"4".to_vec()),
250        ];
251        let marker = extend_and_sign(&log, &turn_events, &s).expect("sign");
252
253        let mut persisted = turn_events.clone();
254        persisted.push(marker);
255
256        // Untampered: verifies cleanly.
257        verify_replay(&persisted, &pk_hex(&s)).expect("untampered replay must verify");
258
259        // Flip one byte of a persisted event's payload — the "torn write /
260        // quiet rewrite" the audit describes.
261        persisted[1].payload[0] ^= 0xFF;
262        let err = verify_replay(&persisted, &pk_hex(&s))
263            .expect_err("tampered replay must report an integrity violation");
264        assert!(
265            matches!(err, IntegrityError::RootMismatch { .. }),
266            "expected a root mismatch, got {err:?}"
267        );
268    }
269
270    /// The MMR is blind to the trust tag: a leaf is `(kind, payload)`, so two
271    /// event sequences differing ONLY in their tags produce a byte-identical
272    /// root at an identical leaf count, and each sequence verifies against the
273    /// other's marker.
274    ///
275    /// This is why a `RewriteDecision::Replace` in the event-log host must
276    /// carry the original event's tag forward rather than rebuild the survivor
277    /// with a default one. A rewrite whose only effect was to reset tags would
278    /// re-sign the same root at the same position, leaving a reader pinned to
279    /// the partition's head unable to see that anything happened — the failure
280    /// re-rooting a rewritten partition exists to close. It also bounds what
281    /// tamper-evidence covers: the trust byte sits outside it entirely.
282    #[test]
283    fn the_signed_root_is_blind_to_the_trust_tag() {
284        let s = signer();
285        let tagged = vec![
286            Event::trusted("user_msg", b"a".to_vec()),
287            Event::quarantined("tool_result", b"b".to_vec()),
288        ];
289        let untagged = vec![
290            Event::new("user_msg", b"a".to_vec()),
291            Event::new("tool_result", b"b".to_vec()),
292        ];
293
294        let tagged_tree = rebuild_from_events(&tagged).expect("rebuild tagged");
295        let untagged_tree = rebuild_from_events(&untagged).expect("rebuild untagged");
296        assert_eq!(
297            tagged_tree.root().expect("tagged root"),
298            untagged_tree.root().expect("untagged root"),
299            "the trust tag is not a leaf input, so it cannot move the root"
300        );
301        assert_eq!(
302            tagged_tree.leaf_count().expect("tagged leaves"),
303            untagged_tree.leaf_count().expect("untagged leaves")
304        );
305
306        // The marker signed over one sequence verifies over the other, which is
307        // the same statement from the verifier's side: an on-disk flip of a
308        // trust tag is undetectable here.
309        let marker = extend_and_sign(&VerifiableLog::new(), &tagged, &s).expect("sign tagged");
310        let mut swapped = untagged;
311        swapped.push(marker);
312        verify_replay(&swapped, &pk_hex(&s))
313            .expect("a tag-only difference does not disturb verification");
314    }
315
316    #[test]
317    fn mmr_verify_replay_accepts_multi_turn_untampered_log() {
318        let log = VerifiableLog::new();
319        let s = signer();
320        let mut persisted = Vec::new();
321
322        for turn in 0..3u8 {
323            let events = vec![
324                Event::new("user_msg", vec![turn]),
325                Event::new("output_msg", vec![turn, turn]),
326            ];
327            let marker = extend_and_sign(&log, &events, &s).expect("sign");
328            persisted.extend(events);
329            persisted.push(marker);
330        }
331
332        verify_replay(&persisted, &pk_hex(&s)).expect("three untampered turns must verify");
333    }
334
335    #[test]
336    fn replay_spans_attestation_rotation_only_with_explicit_history() {
337        use polyc_crypto::signing_role::{JournalAttestationRole, RoleTrustSet};
338
339        let first = JournalAttestationSigner::from_seed(71);
340        let second = JournalAttestationSigner::from_seed(72);
341        let log = VerifiableLog::new();
342        let first_events = vec![Event::new("user_msg", b"before".to_vec())];
343        let first_marker = extend_and_sign(&log, &first_events, &first).expect("first root");
344        let second_events = vec![Event::new("output_msg", b"after".to_vec())];
345        let second_marker = extend_and_sign(&log, &second_events, &second).expect("second root");
346        let persisted = [
347            first_events,
348            vec![first_marker],
349            second_events,
350            vec![second_marker],
351        ]
352        .concat();
353
354        let current_only = RoleTrustSet::<JournalAttestationRole>::current(&second);
355        assert!(verify_replay_with_trust(&persisted, &current_only).is_err());
356        let history = RoleTrustSet::<JournalAttestationRole>::checked(vec![
357            second.identity(),
358            first.identity(),
359        ])
360        .expect("valid history");
361        verify_replay_with_trust(&persisted, &history)
362            .expect("retired root remains verifiable during rotation overlap");
363    }
364
365    #[test]
366    fn mmr_verify_replay_rejects_root_signed_by_a_different_key() {
367        let log = VerifiableLog::new();
368        let s = signer();
369        let events = vec![Event::new("user_msg", b"hi".to_vec())];
370        let marker = extend_and_sign(&log, &events, &s).expect("sign");
371        let mut persisted = events;
372        persisted.push(marker);
373
374        let other = JournalAttestationSigner::from_seed(999);
375        let err = verify_replay(&persisted, &pk_hex(&other))
376            .expect_err("a root signed under a different key must not verify");
377        assert!(matches!(err, IntegrityError::SignatureInvalid { .. }));
378    }
379
380    #[test]
381    fn mmr_verify_replay_accepts_partition_with_no_signed_roots_yet() {
382        let events = vec![Event::new("user_msg", b"no marker yet".to_vec())];
383        verify_replay(&events, &pk_hex(&signer())).expect("no markers is trivially fine");
384    }
385
386    /// Verifying a tail against the tree the earlier turns already built is the
387    /// same check as verifying the whole partition from empty — that equality
388    /// is what lets a commit verify what it just appended without replaying
389    /// everything before it.
390    #[test]
391    fn verifying_a_tail_against_a_running_tree_matches_a_full_replay() {
392        use polyc_crypto::signing_role::{JournalAttestationRole, RoleTrustSet};
393
394        let s = signer();
395        let trust = RoleTrustSet::<JournalAttestationRole>::current(&s);
396        let writer = VerifiableLog::new();
397        let mut persisted = Vec::new();
398        let running = VerifiableLog::new();
399
400        for turn in 0..4u8 {
401            let events = vec![
402                Event::new("user_msg", vec![turn]),
403                Event::new("output_msg", vec![turn, turn]),
404            ];
405            let marker = extend_and_sign(&writer, &events, &s).expect("sign");
406            let mut tail = events;
407            tail.push(marker);
408
409            verify_extension_with_trust(&running, &tail, &trust)
410                .expect("each tail verifies against the tree its predecessors built");
411            persisted.extend(tail);
412            verify_replay_with_trust(&persisted, &trust).expect("and so does the whole partition");
413            assert_eq!(running.root().unwrap(), writer.root().unwrap());
414            assert_eq!(running.leaf_count().unwrap(), writer.leaf_count().unwrap());
415        }
416
417        // A tail whose content was altered after the host signed over it is a
418        // root mismatch, caught against the running tree exactly as a full
419        // replay catches it.
420        let events = vec![Event::new("user_msg", b"honest".to_vec())];
421        let marker = extend_and_sign(&writer, &events, &s).expect("sign");
422        let mut tampered = events;
423        tampered[0].payload[0] ^= 0xFF;
424        tampered.push(marker);
425        assert!(matches!(
426            verify_extension_with_trust(&running, &tampered, &trust)
427                .expect_err("a tampered tail must not verify"),
428            IntegrityError::RootMismatch { .. }
429        ));
430    }
431
432    #[test]
433    fn mmr_rebuild_from_events_skips_marker_events() {
434        let log = VerifiableLog::new();
435        let s = signer();
436        let events = vec![
437            Event::new("user_msg", b"a".to_vec()),
438            Event::new("output_msg", b"b".to_vec()),
439        ];
440        let marker = extend_and_sign(&log, &events, &s).expect("sign");
441        let mut persisted = events;
442        persisted.push(marker);
443
444        let rebuilt = rebuild_from_events(&persisted).expect("rebuild");
445        assert_eq!(rebuilt.leaf_count().unwrap(), 2, "markers are not leaves");
446        assert_eq!(rebuilt.root().unwrap(), log.root().unwrap());
447    }
448}