Skip to main content

kaizen/extensions/
hash_chain.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3use crate::core::event::Event;
4use crate::store::Store;
5use anyhow::Result;
6use rusqlite::{OptionalExtension, params};
7use serde::{Deserialize, Serialize};
8
9const GENESIS: &str = "blake3:genesis";
10
11#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
12pub struct HashChainReport {
13    pub checked_events: u64,
14    pub verified_events: u64,
15    pub unverifiable_events: u64,
16    pub broken_events: Vec<HashBreak>,
17}
18
19#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
20pub struct HashBreak {
21    pub session_id: String,
22    pub seq: u64,
23    pub reason: String,
24}
25
26pub fn store_event_hash(store: &Store, event: &Event) -> Result<()> {
27    let prev = previous_hash(store, &event.session_id, event.seq)?;
28    let hash = event_hash(&prev, event)?;
29    store.conn().execute(
30        "INSERT INTO event_hashes(session_id, seq, prev_hash, event_hash)
31         VALUES (?1, ?2, ?3, ?4)
32         ON CONFLICT(session_id, seq) DO UPDATE SET
33            prev_hash=excluded.prev_hash, event_hash=excluded.event_hash",
34        params![event.session_id, event.seq as i64, prev, hash],
35    )?;
36    Ok(())
37}
38
39pub fn verify(store: &Store, workspace: &str, session_id: Option<&str>) -> Result<HashChainReport> {
40    session_ids(store, workspace, session_id)?.iter().try_fold(
41        HashChainReport::default(),
42        |mut report, id| {
43            verify_session(store, id, &mut report)?;
44            Ok(report)
45        },
46    )
47}
48
49fn verify_session(store: &Store, session_id: &str, report: &mut HashChainReport) -> Result<()> {
50    let mut prev = None;
51    for event in store.list_events_for_session(session_id)? {
52        report.checked_events += 1;
53        match stored_hash(store, session_id, event.seq)? {
54            Some(row) => verify_row(report, &mut prev, &event, row)?,
55            None => report.unverifiable_events += 1,
56        }
57    }
58    Ok(())
59}
60
61fn verify_row(
62    report: &mut HashChainReport,
63    prev: &mut Option<String>,
64    event: &Event,
65    row: (String, String),
66) -> Result<()> {
67    let (stored_prev, stored_hash) = row;
68    if prev.as_deref().is_some_and(|p| p != stored_prev) {
69        push_break(report, event, "previous hash mismatch");
70    }
71    if event_hash(&stored_prev, event)? != stored_hash {
72        push_break(report, event, "event hash mismatch");
73    } else {
74        report.verified_events += 1;
75    }
76    *prev = Some(stored_hash);
77    Ok(())
78}
79
80fn push_break(report: &mut HashChainReport, event: &Event, reason: &str) {
81    report.broken_events.push(HashBreak {
82        session_id: event.session_id.clone(),
83        seq: event.seq,
84        reason: reason.to_string(),
85    });
86}
87
88fn session_ids(store: &Store, workspace: &str, session_id: Option<&str>) -> Result<Vec<String>> {
89    Ok(match session_id {
90        Some(id) => vec![id.to_string()],
91        None => store
92            .list_sessions(workspace)?
93            .into_iter()
94            .map(|session| session.id)
95            .collect(),
96    })
97}
98
99fn stored_hash(store: &Store, session_id: &str, seq: u64) -> Result<Option<(String, String)>> {
100    store
101        .conn()
102        .query_row(
103            "SELECT prev_hash, event_hash FROM event_hashes WHERE session_id=?1 AND seq=?2",
104            params![session_id, seq as i64],
105            |row| Ok((row.get(0)?, row.get(1)?)),
106        )
107        .optional()
108        .map_err(Into::into)
109}
110
111fn previous_hash(store: &Store, session_id: &str, seq: u64) -> Result<String> {
112    store
113        .conn()
114        .query_row(
115            "SELECT event_hash FROM event_hashes
116             WHERE session_id=?1 AND seq < ?2 ORDER BY seq DESC LIMIT 1",
117            params![session_id, seq as i64],
118            |row| row.get(0),
119        )
120        .optional()
121        .map(|v| v.unwrap_or_else(|| GENESIS.to_string()))
122        .map_err(Into::into)
123}
124
125fn event_hash(prev: &str, event: &Event) -> Result<String> {
126    let mut hasher = blake3::Hasher::new();
127    hasher.update(prev.as_bytes());
128    hasher.update(&serde_json::to_vec(event)?);
129    Ok(format!(
130        "blake3:{}",
131        hex::encode(hasher.finalize().as_bytes())
132    ))
133}