use rusqlite::{OptionalExtension, params}; use crate::{ domain::{AuditEvent, DeviceId, FolderId}, storage::StateStore, }; #[derive(Clone, Debug, Eq, PartialEq)] pub struct AuditHead { pub author_device_id: DeviceId, pub sequence: u64, pub event_hash: String, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct AuditIntegrityFault { pub author_device_id: DeviceId, pub detail: String, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct AuditVerification { pub event_count: usize, pub heads: Vec, pub faults: Vec, } impl AuditVerification { pub fn is_valid(&self) -> bool { self.faults.is_empty() } } impl StateStore { pub fn next_audit_sequence_and_parent( &self, folder_id: FolderId, author_device_id: &str, ) -> anyhow::Result<(u64, Option)> { let head = self.audit_head(folder_id, author_device_id)?; Ok(match head { Some(head) => (head.sequence + 1, Some(head.event_hash)), None => (1, None), }) } pub fn append_audit_event(&self, event: &AuditEvent) -> anyhow::Result<()> { event.validate()?; let transaction = self.connection.unchecked_transaction()?; append_audit_event(&transaction, event)?; transaction.commit()?; Ok(()) } pub fn audit_events(&self, folder_id: FolderId) -> anyhow::Result> { let mut statement = self.connection.prepare( "SELECT event FROM audit_events WHERE folder_id = ?1 ORDER BY author_device_id, sequence", )?; statement .query_map(params![folder_id.to_string()], |row| { row.get::<_, String>(0) })? .map(|event| serde_json::from_str(&event?).map_err(Into::into)) .collect() } pub fn audit_heads(&self, folder_id: FolderId) -> anyhow::Result> { let mut statement = self.connection.prepare( "SELECT author_device_id, sequence, event_hash FROM audit_heads WHERE folder_id = ?1 ORDER BY author_device_id", )?; statement .query_map(params![folder_id.to_string()], |row| { Ok(AuditHead { author_device_id: row.get(0)?, sequence: read_sequence(row.get::<_, i64>(1)?)?, event_hash: row.get(2)?, }) })? .collect::, _>>() .map_err(Into::into) } pub fn verify_audit(&self, folder_id: FolderId) -> anyhow::Result { let events = self.audit_events(folder_id)?; let mut faults = Vec::new(); let mut expected_by_author = std::collections::BTreeMap::new(); for event in &events { if let Err(error) = validate_event_link(event, &mut expected_by_author) { faults.push(AuditIntegrityFault { author_device_id: event.author_device_id.clone(), detail: error.to_string(), }); } } let heads = self.audit_heads(folder_id)?; for head in &heads { let expected = expected_by_author.get(&head.author_device_id); if expected == Some(&(head.sequence, head.event_hash.clone())) { continue; } faults.push(AuditIntegrityFault { author_device_id: head.author_device_id.clone(), detail: "stored audit head does not match its event chain".to_owned(), }); } faults.extend(self.audit_integrity_faults(folder_id)?); Ok(AuditVerification { event_count: events.len(), heads, faults, }) } pub fn record_audit_fault( &self, folder_id: FolderId, author_device_id: &str, detail: &str, ) -> anyhow::Result<()> { self.connection.execute( "INSERT INTO audit_integrity_faults (folder_id, author_device_id, detected_at, detail) VALUES (?1, ?2, datetime('now'), ?3)", params![folder_id.to_string(), author_device_id, detail], )?; Ok(()) } fn audit_head( &self, folder_id: FolderId, author_device_id: &str, ) -> anyhow::Result> { self.connection .query_row( "SELECT sequence, event_hash FROM audit_heads WHERE folder_id = ?1 AND author_device_id = ?2", params![folder_id.to_string(), author_device_id], |row| Ok(AuditHead { author_device_id: author_device_id.to_owned(), sequence: read_sequence(row.get::<_, i64>(0)?)?, event_hash: row.get(1)?, }), ) .optional() .map_err(Into::into) } fn audit_integrity_faults( &self, folder_id: FolderId, ) -> anyhow::Result> { let mut statement = self.connection.prepare( "SELECT author_device_id, detail FROM audit_integrity_faults WHERE folder_id = ?1 ORDER BY detected_at", )?; statement .query_map(params![folder_id.to_string()], |row| { Ok(AuditIntegrityFault { author_device_id: row.get(0)?, detail: row.get(1)?, }) })? .collect::, _>>() .map_err(Into::into) } } pub(super) fn append_audit_event( transaction: &rusqlite::Transaction<'_>, event: &AuditEvent, ) -> anyhow::Result<()> { event.validate()?; if event_already_exists(transaction, event)? { return Ok(()); } let current_head = transaction .query_row( "SELECT sequence, event_hash FROM audit_heads WHERE folder_id = ?1 AND author_device_id = ?2", params![event.folder_id.to_string(), event.author_device_id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) .optional()?; validate_new_event(event, current_head)?; let event_hash = event.hash()?; transaction.execute( "INSERT INTO audit_events (folder_id, author_device_id, sequence, event_hash, parent_hash, event) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![event.folder_id.to_string(), event.author_device_id, i64::try_from(event.sequence)?, event_hash, event.parent_hash, serde_json::to_string(event)?], )?; transaction.execute( "INSERT INTO audit_heads (folder_id, author_device_id, sequence, event_hash) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(folder_id, author_device_id) DO UPDATE SET sequence = excluded.sequence, event_hash = excluded.event_hash", params![event.folder_id.to_string(), event.author_device_id, i64::try_from(event.sequence)?, event.hash()?], )?; Ok(()) } fn event_already_exists( transaction: &rusqlite::Transaction<'_>, event: &AuditEvent, ) -> anyhow::Result { let stored_hash = transaction .query_row( "SELECT event_hash FROM audit_events WHERE folder_id = ?1 AND author_device_id = ?2 AND sequence = ?3", params![event.folder_id.to_string(), event.author_device_id, i64::try_from(event.sequence)?], |row| row.get::<_, String>(0), ) .optional()?; match stored_hash { None => Ok(false), Some(stored_hash) if stored_hash == event.hash()? => Ok(true), Some(_) => anyhow::bail!("audit event conflicts with an existing author sequence"), } } fn validate_new_event( event: &AuditEvent, current_head: Option<(i64, String)>, ) -> anyhow::Result<()> { match current_head { None if event.sequence == 1 && event.parent_hash.is_none() => Ok(()), Some((sequence, hash)) if event.sequence == u64::try_from(sequence)? + 1 && event.parent_hash.as_deref() == Some(&hash) => { Ok(()) } None => anyhow::bail!("first audit event must have sequence 1 and no parent hash"), Some(_) => anyhow::bail!("audit event does not extend the current author chain"), } } fn read_sequence(sequence: i64) -> rusqlite::Result { u64::try_from(sequence).map_err(|error| { rusqlite::Error::FromSqlConversionFailure( 0, rusqlite::types::Type::Integer, Box::new(error), ) }) } fn validate_event_link( event: &AuditEvent, expected_by_author: &mut std::collections::BTreeMap, ) -> anyhow::Result<()> { event.validate()?; let current_head = expected_by_author.get(&event.author_device_id).cloned(); let current_head = match current_head { Some((sequence, hash)) => Some((i64::try_from(sequence)?, hash)), None => None, }; validate_new_event(event, current_head)?; expected_by_author.insert( event.author_device_id.clone(), (event.sequence, event.hash()?), ); Ok(()) } #[cfg(test)] mod tests { use tempfile::TempDir; use uuid::Uuid; use crate::{ domain::{AuditEvent, AuditEventKind, FolderRoster, MemberRole, RosterMember}, storage::{AppPaths, StateStore}, }; #[test] fn verifies_a_signed_author_chain() -> anyhow::Result<()> { let directory = TempDir::new()?; let store = StateStore::open(&AppPaths::from_data_directory( directory.path().join("state"), )?)?; let identity = iroh::SecretKey::generate(); let folder_id = Uuid::new_v4(); let roster = signed_owner_roster(folder_id, &identity, "secret")?; let mut first = AuditEvent::create( folder_id, identity.public().to_string(), 1, None, AuditEventKind::ManifestCommitted, Some("one".to_owned()), roster.hash()?, roster.clone(), time::OffsetDateTime::UNIX_EPOCH, ); let first = first.sign(&identity)?; store.append_audit_event(&first)?; let (sequence, parent) = store.next_audit_sequence_and_parent(folder_id, &identity.public().to_string())?; let mut second = AuditEvent::create( folder_id, identity.public().to_string(), sequence, parent, AuditEventKind::ManifestCommitted, Some("two".to_owned()), roster.hash()?, roster, time::OffsetDateTime::UNIX_EPOCH, ); let second = second.sign(&identity)?; store.append_audit_event(&second)?; let verification = store.verify_audit(folder_id)?; assert!(verification.is_valid()); assert_eq!(verification.event_count, 2); Ok(()) } #[test] fn accepts_an_event_from_a_member_removed_later() -> anyhow::Result<()> { let directory = TempDir::new()?; let store = StateStore::open(&AppPaths::from_data_directory( directory.path().join("state"), )?)?; let owner = iroh::SecretKey::generate(); let member = iroh::SecretKey::generate(); let folder_id = Uuid::new_v4(); let roster = FolderRoster::create( folder_id, "before".to_owned(), RosterMember { device_id: owner.public().to_string(), display_name: None, role: MemberRole::Owner, }, )? .add_member(RosterMember { device_id: member.public().to_string(), display_name: None, role: MemberRole::Member, })? .sign(&owner)?; let event = AuditEvent::create( folder_id, member.public().to_string(), 1, None, AuditEventKind::ManifestCommitted, Some("before-removal".to_owned()), roster.hash()?, roster, time::OffsetDateTime::UNIX_EPOCH, ) .sign(&member)?; store.append_audit_event(&event)?; assert!(store.verify_audit(folder_id)?.is_valid()); Ok(()) } #[test] fn rejects_a_member_attempting_a_roster_change() -> anyhow::Result<()> { let directory = TempDir::new()?; let store = StateStore::open(&AppPaths::from_data_directory( directory.path().join("state"), )?)?; let owner = iroh::SecretKey::generate(); let member = iroh::SecretKey::generate(); let folder_id = Uuid::new_v4(); let roster = FolderRoster::create( folder_id, "secret".to_owned(), RosterMember { device_id: owner.public().to_string(), display_name: None, role: MemberRole::Owner, }, )? .add_member(RosterMember { device_id: member.public().to_string(), display_name: None, role: MemberRole::Member, })? .sign(&owner)?; let event = AuditEvent::create( folder_id, member.public().to_string(), 1, None, AuditEventKind::RosterUpdated, None, roster.hash()?, roster, time::OffsetDateTime::UNIX_EPOCH, ) .sign(&member)?; assert!(store.append_audit_event(&event).is_err()); Ok(()) } fn signed_owner_roster( folder_id: uuid::Uuid, identity: &iroh::SecretKey, capability: &str, ) -> anyhow::Result { FolderRoster::create( folder_id, capability.to_owned(), RosterMember { device_id: identity.public().to_string(), display_name: None, role: MemberRole::Owner, }, )? .sign(identity) } }