polyc_eventlog/integrity.rs
1//! Per-conversation tamper-evidence over the event log (#799).
2//!
3//! [`crate::EventLog`] 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::approval::ApprovalSigner;
32use polyc_mmr::{SignedRoot, VerifiableLog, verify_root_signature};
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: &ApprovalSigner,
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 log = VerifiableLog::new();
148 for event in events {
149 if event.kind == MMR_SIGNED_ROOT_KIND {
150 let leaf_count = log.leaf_count()?;
151 let root: SignedRoot = serde_json::from_slice(&event.payload).map_err(|source| {
152 IntegrityError::MalformedRoot {
153 leaf_count_hint: leaf_count,
154 source,
155 }
156 })?;
157 let sig_ok = verify_root_signature(&root, expected_signer_pk_hex).unwrap_or(false);
158 if !sig_ok {
159 return Err(IntegrityError::SignatureInvalid { leaf_count });
160 }
161 let computed_root = log.root()?;
162 let computed_root_hex = hex::encode(computed_root.as_ref());
163 if root.leaf_count != leaf_count || root.root_hex != computed_root_hex {
164 return Err(IntegrityError::RootMismatch {
165 leaf_count,
166 expected_root_hex: root.root_hex,
167 computed_root_hex,
168 });
169 }
170 } else {
171 log.append(&event.kind, &event.payload)?;
172 }
173 }
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
180 use super::*;
181
182 fn signer() -> ApprovalSigner {
183 ApprovalSigner::from_seed(7)
184 }
185
186 fn pk_hex(signer: &ApprovalSigner) -> String {
187 hex::encode(signer.public_key_bytes())
188 }
189
190 /// The pinning test (#799): append a turn's worth of events, sign the
191 /// root, replay, flip one byte of one persisted event's payload, and
192 /// assert replay reports an integrity violation. Before this module
193 /// existed nothing checked this at all — `verify_replay` didn't exist.
194 #[test]
195 fn mmr_verify_replay_detects_tampered_event() {
196 let log = VerifiableLog::new();
197 let s = signer();
198 let turn_events = vec![
199 Event::new("user_msg", b"what is 2+2?".to_vec()),
200 Event::new("output_msg", b"4".to_vec()),
201 ];
202 let marker = extend_and_sign(&log, &turn_events, &s).expect("sign");
203
204 let mut persisted = turn_events.clone();
205 persisted.push(marker);
206
207 // Untampered: verifies cleanly.
208 verify_replay(&persisted, &pk_hex(&s)).expect("untampered replay must verify");
209
210 // Flip one byte of a persisted event's payload — the "torn write /
211 // quiet rewrite" the audit describes.
212 persisted[1].payload[0] ^= 0xFF;
213 let err = verify_replay(&persisted, &pk_hex(&s))
214 .expect_err("tampered replay must report an integrity violation");
215 assert!(
216 matches!(err, IntegrityError::RootMismatch { .. }),
217 "expected a root mismatch, got {err:?}"
218 );
219 }
220
221 #[test]
222 fn mmr_verify_replay_accepts_multi_turn_untampered_log() {
223 let log = VerifiableLog::new();
224 let s = signer();
225 let mut persisted = Vec::new();
226
227 for turn in 0..3u8 {
228 let events = vec![
229 Event::new("user_msg", vec![turn]),
230 Event::new("output_msg", vec![turn, turn]),
231 ];
232 let marker = extend_and_sign(&log, &events, &s).expect("sign");
233 persisted.extend(events);
234 persisted.push(marker);
235 }
236
237 verify_replay(&persisted, &pk_hex(&s)).expect("three untampered turns must verify");
238 }
239
240 #[test]
241 fn mmr_verify_replay_rejects_root_signed_by_a_different_key() {
242 let log = VerifiableLog::new();
243 let s = signer();
244 let events = vec![Event::new("user_msg", b"hi".to_vec())];
245 let marker = extend_and_sign(&log, &events, &s).expect("sign");
246 let mut persisted = events;
247 persisted.push(marker);
248
249 let other = ApprovalSigner::from_seed(999);
250 let err = verify_replay(&persisted, &pk_hex(&other))
251 .expect_err("a root signed under a different key must not verify");
252 assert!(matches!(err, IntegrityError::SignatureInvalid { .. }));
253 }
254
255 #[test]
256 fn mmr_verify_replay_accepts_partition_with_no_signed_roots_yet() {
257 let events = vec![Event::new("user_msg", b"no marker yet".to_vec())];
258 verify_replay(&events, &pk_hex(&signer())).expect("no markers is trivially fine");
259 }
260
261 #[test]
262 fn mmr_rebuild_from_events_skips_marker_events() {
263 let log = VerifiableLog::new();
264 let s = signer();
265 let events = vec![
266 Event::new("user_msg", b"a".to_vec()),
267 Event::new("output_msg", b"b".to_vec()),
268 ];
269 let marker = extend_and_sign(&log, &events, &s).expect("sign");
270 let mut persisted = events;
271 persisted.push(marker);
272
273 let rebuilt = rebuild_from_events(&persisted).expect("rebuild");
274 assert_eq!(rebuilt.leaf_count().unwrap(), 2, "markers are not leaves");
275 assert_eq!(rebuilt.root().unwrap(), log.root().unwrap());
276 }
277}