diff --git a/src/app.rs b/src/app.rs index 29aab9b..d5f359d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,10 @@ use time::{Duration, OffsetDateTime}; use crate::{ config::{AppaConfig, ConfiguredFolder}, - domain::{DeviceId, EntryKind, FolderRoster, MemberRole, RosterMember, is_conflict_artifact}, + domain::{ + AuditEvent, AuditEventKind, DeviceId, EntryKind, FolderRoster, MemberRole, RosterMember, + is_conflict_artifact, manifest_root_hash, + }, iroh::NodeHost, protocol::{INVITATION_PROTOCOL_VERSION, Invite, decode_invite, encode_invite}, storage::{AppPaths, AuditVerification, FolderConfig, ManifestRevision, PeerInfo, StateStore}, @@ -120,7 +123,9 @@ impl AppaService { }; let mut roster = FolderRoster::create(folder.id, folder.capability.clone(), owner)?; roster.sign(&owner_identity)?; - self.state_store.save_roster(&roster) + let audit_event = self.roster_audit_event(&roster)?; + self.state_store + .save_roster_with_audit(&roster, &audit_event) } pub fn write_config_template(&self, config_path: &Path) -> AppResult<()> { @@ -304,8 +309,12 @@ impl AppaService { roster.remove_member(device_id)?; roster.rotate_capability(self.state_store.new_capability()); roster.sign(&self.paths.load_identity()?)?; - self.state_store - .replace_capability_and_roster(folder.id, &roster)?; + let audit_event = self.roster_audit_event(&roster)?; + self.state_store.replace_capability_and_roster_with_audit( + folder.id, + &roster, + &audit_event, + )?; Ok(()) } @@ -466,7 +475,32 @@ impl AppaService { role: MemberRole::Member, })?; roster.sign(&self.paths.load_identity()?)?; - self.state_store.save_roster(&roster) + let audit_event = self.roster_audit_event(&roster)?; + self.state_store + .save_roster_with_audit(&roster, &audit_event) + } + + fn roster_audit_event(&self, roster: &FolderRoster) -> AppResult { + let identity = self.paths.load_identity()?; + let author_device_id = identity.public().to_string(); + if roster.owner_device_id != author_device_id { + anyhow::bail!("only the folder owner can sign a roster audit event"); + } + let (sequence, parent_hash) = self + .state_store + .next_audit_sequence_and_parent(roster.folder_id, &author_device_id)?; + let manifest = self.state_store.load_manifest(roster.folder_id)?; + let mut event = AuditEvent::create( + roster.folder_id, + author_device_id, + sequence, + parent_hash, + AuditEventKind::RosterUpdated, + Some(manifest_root_hash(&manifest)?), + roster.hash()?, + ); + event.sign(&identity)?; + Ok(event) } fn device_id(&self) -> AppResult { diff --git a/src/app/sync.rs b/src/app/sync.rs index 66c73a5..06a3462 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -148,7 +148,8 @@ impl AppaService { manifest: &crate::domain::Manifest, ) -> AppResult<()> { let audit_event = self.manifest_audit_event(manifest)?; - self.state_store.save_manifest_with_audit(manifest, &audit_event)?; + self.state_store + .save_manifest_with_audit(manifest, &audit_event)?; node.publish_manifest(manifest.clone()).await } diff --git a/src/daemon.rs b/src/daemon.rs index f4de058..0b96fe3 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -204,7 +204,11 @@ fn service_history(service: &AppaService, folder_path: &Path) -> anyhow::Result< .collect()) } -fn service_audit(service: &AppaService, folder_path: &Path, verify: bool) -> anyhow::Result { +fn service_audit( + service: &AppaService, + folder_path: &Path, + verify: bool, +) -> anyhow::Result { if verify { let verification = service.verify_audit(folder_path)?; if verification.is_valid() { diff --git a/src/ipc.rs b/src/ipc.rs index b37f4d5..d4c16b9 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -188,7 +188,11 @@ impl DaemonClient { } pub async fn audit(&self, folder_path: PathBuf, verify: bool) -> anyhow::Result { - self.request_text(Command::Audit { folder_path, verify }).await + self.request_text(Command::Audit { + folder_path, + verify, + }) + .await } pub async fn conflicts(&self, folder_path: PathBuf) -> anyhow::Result { diff --git a/src/storage.rs b/src/storage.rs index 3f3aac4..1568425 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -16,8 +16,8 @@ use crate::{ protocol::Invite, }; -mod fingerprints; mod audit; +mod fingerprints; mod manifests; mod materializations; mod paths; @@ -41,11 +41,11 @@ const FOLDER_SCOPED_TABLES: &[&str] = &[ "audit_integrity_faults", ]; +pub use audit::AuditVerification; pub use fingerprints::FileFingerprint; pub use manifests::ManifestRevision; pub use paths::AppPaths; pub use peers::PeerInfo; -pub use audit::AuditVerification; const DEFAULT_HISTORY_REVISIONS: usize = 100; diff --git a/src/storage/audit.rs b/src/storage/audit.rs index 9f87826..f247bc5 100644 --- a/src/storage/audit.rs +++ b/src/storage/audit.rs @@ -58,7 +58,9 @@ impl StateStore { "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))? + .query_map(params![folder_id.to_string()], |row| { + row.get::<_, String>(0) + })? .map(|event| serde_json::from_str(&event?).map_err(Into::into)) .collect() } @@ -99,7 +101,11 @@ impl StateStore { }) } - fn audit_head(&self, folder_id: FolderId, author_device_id: &str) -> anyhow::Result> { + 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", @@ -114,15 +120,20 @@ impl StateStore { .map_err(Into::into) } - fn audit_integrity_faults(&self, folder_id: FolderId) -> anyhow::Result> { + 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)?, - }))? + .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) } @@ -153,23 +164,31 @@ pub(super) fn append_audit_event( Ok(()) } -fn validate_new_event(event: &AuditEvent, current_head: Option<(i64, String)>) -> anyhow::Result<()> { +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(()), + && 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), - )) + u64::try_from(sequence).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Integer, + Box::new(error), + ) + }) } fn validate_event_link( @@ -183,7 +202,10 @@ fn validate_event_link( None => None, }; validate_new_event(event, current_head)?; - expected_by_author.insert(event.author_device_id.clone(), (event.sequence, event.hash()?)); + expected_by_author.insert( + event.author_device_id.clone(), + (event.sequence, event.hash()?), + ); Ok(()) } @@ -192,19 +214,41 @@ mod tests { use tempfile::TempDir; use uuid::Uuid; - use crate::{domain::{AuditEvent, AuditEventKind}, storage::{AppPaths, StateStore}}; + use crate::{ + domain::{AuditEvent, AuditEventKind}, + 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 store = StateStore::open(&AppPaths::from_data_directory( + directory.path().join("state"), + )?)?; let identity = iroh::SecretKey::generate(); let folder_id = Uuid::new_v4(); - let mut first = AuditEvent::create(folder_id, identity.public().to_string(), 1, None, AuditEventKind::ManifestCommitted, Some("one".to_owned()), "roster".to_owned()); + let mut first = AuditEvent::create( + folder_id, + identity.public().to_string(), + 1, + None, + AuditEventKind::ManifestCommitted, + Some("one".to_owned()), + "roster".to_owned(), + ); 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".to_owned()); + 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".to_owned(), + ); second.sign(&identity)?; store.append_audit_event(&second)?; diff --git a/src/storage/manifests.rs b/src/storage/manifests.rs index 441130a..a1fcb8b 100644 --- a/src/storage/manifests.rs +++ b/src/storage/manifests.rs @@ -4,11 +4,11 @@ use rusqlite::{OptionalExtension, params}; use serde::{Deserialize, Serialize}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use crate::domain::AuditEvent; use crate::{ domain::{Entry, FolderId, Manifest}, storage::{StateStore, audit::append_audit_event, prune_manifest_history}, }; -use crate::domain::AuditEvent; const HISTORY_CHECKPOINT_INTERVAL: u64 = 20; const COMPRESSION_LEVEL: i32 = 3; diff --git a/src/storage/rosters.rs b/src/storage/rosters.rs index aab4111..d87ed94 100644 --- a/src/storage/rosters.rs +++ b/src/storage/rosters.rs @@ -1,15 +1,34 @@ use rusqlite::{OptionalExtension, params}; use crate::{ - domain::{DeviceId, FolderId, FolderRoster}, - storage::StateStore, + domain::{AuditEvent, DeviceId, FolderId, FolderRoster}, + storage::{StateStore, audit::append_audit_event}, }; impl StateStore { + #[cfg(test)] pub fn replace_capability_and_roster( &self, folder_id: FolderId, roster: &FolderRoster, + ) -> anyhow::Result<()> { + self.replace_capability_and_roster_with_optional_audit(folder_id, roster, None) + } + + pub fn replace_capability_and_roster_with_audit( + &self, + folder_id: FolderId, + roster: &FolderRoster, + audit_event: &AuditEvent, + ) -> anyhow::Result<()> { + self.replace_capability_and_roster_with_optional_audit(folder_id, roster, Some(audit_event)) + } + + fn replace_capability_and_roster_with_optional_audit( + &self, + folder_id: FolderId, + roster: &FolderRoster, + audit_event: Option<&AuditEvent>, ) -> anyhow::Result<()> { if roster.folder_id != folder_id { anyhow::bail!("roster belongs to another folder"); @@ -24,16 +43,40 @@ impl StateStore { "INSERT INTO folder_rosters (folder_id, epoch, roster) VALUES (?1, ?2, ?3) ON CONFLICT(folder_id) DO UPDATE SET epoch = excluded.epoch, roster = excluded.roster", params![folder_id.to_string(), i64::try_from(roster.epoch)?, serde_json::to_string(roster)?], )?; + if let Some(audit_event) = audit_event { + append_audit_event(&transaction, audit_event)?; + } transaction.commit()?; Ok(()) } pub fn save_roster(&self, roster: &FolderRoster) -> anyhow::Result<()> { + self.save_roster_with_optional_audit(roster, None) + } + + pub fn save_roster_with_audit( + &self, + roster: &FolderRoster, + audit_event: &AuditEvent, + ) -> anyhow::Result<()> { + self.save_roster_with_optional_audit(roster, Some(audit_event)) + } + + fn save_roster_with_optional_audit( + &self, + roster: &FolderRoster, + audit_event: Option<&AuditEvent>, + ) -> anyhow::Result<()> { roster.validate()?; - self.connection.execute( + let transaction = self.connection.unchecked_transaction()?; + transaction.execute( "INSERT INTO folder_rosters (folder_id, epoch, roster) VALUES (?1, ?2, ?3) ON CONFLICT(folder_id) DO UPDATE SET epoch = excluded.epoch, roster = excluded.roster", params![roster.folder_id.to_string(), i64::try_from(roster.epoch)?, serde_json::to_string(roster)?], )?; + if let Some(audit_event) = audit_event { + append_audit_event(&transaction, audit_event)?; + } + transaction.commit()?; Ok(()) } diff --git a/src/storage/schema.rs b/src/storage/schema.rs index 97e360b..ba50beb 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -7,55 +7,56 @@ struct Migration { statements: &'static [&'static str], } -const MIGRATIONS: &[Migration] = &[Migration { - version: CURRENT_SCHEMA_VERSION, - statements: &[ - "CREATE TABLE IF NOT EXISTS folders ( +const MIGRATIONS: &[Migration] = &[ + Migration { + version: CURRENT_SCHEMA_VERSION, + statements: &[ + "CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, capability TEXT NOT NULL, mode TEXT NOT NULL DEFAULT '\"send_receive\"' );", - "CREATE TABLE IF NOT EXISTS known_peers ( + "CREATE TABLE IF NOT EXISTS known_peers ( folder_id TEXT NOT NULL, device_id TEXT NOT NULL, endpoint TEXT NOT NULL, last_seen TEXT NOT NULL, PRIMARY KEY (folder_id, device_id) );", - "CREATE TABLE IF NOT EXISTS join_invites ( + "CREATE TABLE IF NOT EXISTS join_invites ( folder_id TEXT PRIMARY KEY NOT NULL, invite TEXT NOT NULL );", - "CREATE TABLE IF NOT EXISTS folder_rosters ( + "CREATE TABLE IF NOT EXISTS folder_rosters ( folder_id TEXT PRIMARY KEY NOT NULL, epoch INTEGER NOT NULL, roster TEXT NOT NULL );", - "CREATE TABLE IF NOT EXISTS manifests ( + "CREATE TABLE IF NOT EXISTS manifests ( folder_id TEXT PRIMARY KEY NOT NULL, manifest TEXT NOT NULL );", - "CREATE TABLE IF NOT EXISTS manifest_history_compact ( + "CREATE TABLE IF NOT EXISTS manifest_history_compact ( revision INTEGER PRIMARY KEY AUTOINCREMENT, folder_id TEXT NOT NULL, saved_at TEXT NOT NULL, kind TEXT NOT NULL, payload BLOB NOT NULL );", - "CREATE TABLE IF NOT EXISTS counters ( + "CREATE TABLE IF NOT EXISTS counters ( folder_id TEXT NOT NULL, device_id TEXT NOT NULL, counter INTEGER NOT NULL, PRIMARY KEY (folder_id, device_id) );", - "CREATE TABLE IF NOT EXISTS sync_state ( + "CREATE TABLE IF NOT EXISTS sync_state ( folder_id TEXT PRIMARY KEY NOT NULL, last_successful_sync TEXT, last_error TEXT );", - "CREATE TABLE IF NOT EXISTS file_fingerprints ( + "CREATE TABLE IF NOT EXISTS file_fingerprints ( folder_id TEXT NOT NULL, path TEXT NOT NULL, size_bytes INTEGER NOT NULL, @@ -64,16 +65,17 @@ const MIGRATIONS: &[Migration] = &[Migration { blob_hash TEXT NOT NULL, PRIMARY KEY (folder_id, path) );", - "CREATE TABLE IF NOT EXISTS pending_materializations ( + "CREATE TABLE IF NOT EXISTS pending_materializations ( folder_id TEXT PRIMARY KEY NOT NULL, entry TEXT NOT NULL, resulting_manifest TEXT NOT NULL );", - ], -}, Migration { - version: CURRENT_SCHEMA_VERSION, - statements: &[ - "CREATE TABLE IF NOT EXISTS audit_events ( + ], + }, + Migration { + version: CURRENT_SCHEMA_VERSION, + statements: &[ + "CREATE TABLE IF NOT EXISTS audit_events ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, sequence INTEGER NOT NULL, @@ -82,21 +84,22 @@ const MIGRATIONS: &[Migration] = &[Migration { event TEXT NOT NULL, PRIMARY KEY (folder_id, author_device_id, sequence) );", - "CREATE TABLE IF NOT EXISTS audit_heads ( + "CREATE TABLE IF NOT EXISTS audit_heads ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, sequence INTEGER NOT NULL, event_hash TEXT NOT NULL, PRIMARY KEY (folder_id, author_device_id) );", - "CREATE TABLE IF NOT EXISTS audit_integrity_faults ( + "CREATE TABLE IF NOT EXISTS audit_integrity_faults ( folder_id TEXT NOT NULL, author_device_id TEXT NOT NULL, detected_at TEXT NOT NULL, detail TEXT NOT NULL );", - ], -}]; + ], + }, +]; pub(super) fn initialize(connection: &mut Connection) -> anyhow::Result<()> { let installed_version = schema_version(connection)?;