Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411use 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<AuditHead>, pub faults: Vec<AuditIntegrityFault>,}
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<String>)> { 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<Vec<AuditEvent>> { 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<Vec<AuditHead>> { 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::<Result<Vec<_>, _>>() .map_err(Into::into) }
pub fn verify_audit(&self, folder_id: FolderId) -> anyhow::Result<AuditVerification> { 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<Option<AuditHead>> { 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<Vec<AuditIntegrityFault>> { 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::<Result<Vec<_>, _>>() .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<bool> { 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> { 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<DeviceId, (u64, String)>,) -> 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> { FolderRoster::create( folder_id, capability.to_owned(), RosterMember { device_id: identity.public().to_string(), display_name: None, role: MemberRole::Owner, }, )? .sign(identity) }}