diff --git a/README.md b/README.md index 691a923..07784f1 100644 --- a/README.md +++ b/README.md @@ -150,4 +150,4 @@ nix develop -c cargo run -p appa-cosmic ## License -MIT or Apache-2.0. +MIT. diff --git a/src/app.rs b/src/app.rs index f99f882..f9a1b3f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -463,6 +463,7 @@ impl AppaService { AuditEventKind::RosterUpdated, Some(manifest_root_hash(&manifest)?), roster.hash()?, + roster.clone(), now, ); event.sign(&identity) diff --git a/src/app/sync.rs b/src/app/sync.rs index c1f9bfe..52c97ce 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -7,7 +7,7 @@ use crate::{ }, }, domain::{AuditEvent, AuditEventKind, FolderRoster, manifest_root_hash}, - iroh::{FolderSession, NodeHost, PeerSummaryResponse}, + iroh::{FolderSession, MAX_AUDIT_EVENTS_PER_RESPONSE, NodeHost, PeerSummaryResponse}, storage::FolderConfig, }; use futures_util::{StreamExt, stream}; @@ -182,6 +182,7 @@ impl AppaService { AuditEventKind::ManifestCommitted, Some(manifest_root_hash(manifest)?), roster.hash()?, + roster.clone(), now, ); event.sign(&identity) @@ -292,7 +293,7 @@ impl AppaService { return Ok(None); } self.save_peer_response(folder, &peer, &roster, peer_endpoints, &capability)?; - self.receive_peer_audit_events(context, &peer, &roster) + self.receive_peer_audit_events(context, &peer) .await?; if summary.root_hash == manifest_root_hash(local_manifest)? { tracing::debug!(folder = %folder.name, peer = %peer.id, "Folder roots match; skipping manifest transfer"); @@ -336,8 +337,8 @@ impl AppaService { &self, context: &SyncContext<'_>, peer: &iroh::EndpointAddr, - roster: &FolderRoster, ) -> AppResult<()> { + loop { let events = match context .node .request_audit_events( @@ -359,13 +360,11 @@ impl AppaService { return Ok(()); } }; + let is_last_page = events.len() < MAX_AUDIT_EVENTS_PER_RESPONSE; for event in events { if event.folder_id != context.folder.id { anyhow::bail!("peer returned an audit event for another folder"); } - if !roster.contains_member(&event.author_device_id) { - anyhow::bail!("peer returned an audit event from a non-member"); - } if let Err(error) = self.state_store.append_audit_event(&event) { let detail = format!( "rejected audit event from {}: {error}", @@ -379,6 +378,10 @@ impl AppaService { anyhow::bail!(detail); } } + if is_last_page { + break; + } + } context .node .publish_audit_events( diff --git a/src/app/tests.rs b/src/app/tests.rs index 85c4706..13f7dbc 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -233,6 +233,73 @@ async fn synchronizes_and_deletes_a_file_between_two_devices() -> anyhow::Result Ok(()) } +#[tokio::test] +async fn synchronizes_audit_events_written_before_the_author_was_revoked() -> anyhow::Result<()> { + let source_data = TempDir::new()?; + let target_data = TempDir::new()?; + let source_folder = TempDir::new()?; + let target_folder = TempDir::new()?; + let source = AppaService::open_at(AppPaths::from_data_directory( + source_data.path().to_owned(), + )?)?; + let target = AppaService::open_at(AppPaths::from_data_directory( + target_data.path().to_owned(), + )?)?; + let initial_folder = source.register_folder(source_folder.path())?; + let former_member = iroh::SecretKey::generate(); + source.enroll_discovered_member(initial_folder.id, &former_member.public().to_string())?; + let historical_roster = source + .state_store + .load_roster(initial_folder.id)? + .expect("historical roster"); + let historical_event = crate::domain::AuditEvent::create( + initial_folder.id, + former_member.public().to_string(), + 1, + None, + crate::domain::AuditEventKind::ManifestCommitted, + Some("manifest-before-revocation".to_owned()), + historical_roster.hash()?, + historical_roster, + OffsetDateTime::UNIX_EPOCH, + ) + .sign(&former_member)?; + source.state_store.append_audit_event(&historical_event)?; + source.enroll_discovered_member(initial_folder.id, &target.device_id()?)?; + source.revoke_member(source_folder.path(), &former_member.public().to_string())?; + let source_config = source.require_folder(source_folder.path())?; + let source_node = source.load_node().await?; + source_node.wait_until_online().await?; + source.sync_with_node(&source_config, &source_node).await?; + let invite = Invite { + protocol_version: INVITATION_PROTOCOL_VERSION, + folder_id: source_config.id, + folder_name: source_config.name.clone(), + inviter_device_id: source_node.endpoint_address().id.to_string(), + inviter_endpoint: source_node.endpoint_address(), + capability: source_config.capability.clone(), + roster: source + .state_store + .load_roster(source_config.id)? + .expect("current roster"), + expires_at: OffsetDateTime::now_utc() + Duration::hours(1), + signature: None, + }; + target + .state_store + .import_invite(target_folder.path(), &invite)?; + + target.sync_once(target_folder.path()).await?; + + let target_events = target.state_store.audit_events(source_config.id)?; + assert!(target_events.iter().any(|event| { + event.author_device_id == former_member.public().to_string() + && event.hash() == historical_event.hash() + })); + source_node.shutdown().await?; + Ok(()) +} + async fn sync_until_file_materializes( appa: &AppaService, folder: &Path, diff --git a/src/domain/audit.rs b/src/domain/audit.rs index 6638805..adcdcdd 100644 --- a/src/domain/audit.rs +++ b/src/domain/audit.rs @@ -5,9 +5,9 @@ use iroh::{EndpointId, SecretKey, Signature}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; -use super::{DeviceId, FolderId, canonical_json_bytes}; +use super::{DeviceId, FolderId, FolderRoster, MemberRole, canonical_json_bytes}; -pub const AUDIT_PROTOCOL_VERSION: u16 = 1; +pub const AUDIT_PROTOCOL_VERSION: u16 = 2; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -27,6 +27,7 @@ pub struct AuditEvent { pub kind: AuditEventKind, pub manifest_root_hash: Option, pub roster_hash: String, + pub roster: FolderRoster, pub occurred_at: OffsetDateTime, pub metadata: BTreeMap, pub signature: Option, @@ -41,6 +42,7 @@ impl AuditEvent { kind: AuditEventKind, manifest_root_hash: Option, roster_hash: String, + roster: FolderRoster, occurred_at: OffsetDateTime, ) -> Self { Self { @@ -52,6 +54,7 @@ impl AuditEvent { kind, manifest_root_hash, roster_hash, + roster, occurred_at, metadata: BTreeMap::new(), signature: None, @@ -73,6 +76,25 @@ impl AuditEvent { if self.sequence == 0 { anyhow::bail!("audit event sequence must be positive"); } + self.roster.validate()?; + if self.roster.folder_id != self.folder_id || self.roster.hash()? != self.roster_hash { + anyhow::bail!("audit event roster does not match its roster hash"); + } + let author = self.roster.members.get(&self.author_device_id); + let is_authorized = match (&self.kind, author.map(|member| &member.role)) { + ( + AuditEventKind::RosterUpdated | AuditEventKind::OwnerIdentityRestored, + Some(MemberRole::Owner), + ) => true, + ( + AuditEventKind::ManifestCommitted, + Some(MemberRole::Owner | MemberRole::Member), + ) => true, + _ => false, + }; + if !is_authorized { + anyhow::bail!("audit event author was not authorized by its roster"); + } let author = self.author_device_id.parse::()?; let signature = self .signature @@ -102,6 +124,7 @@ struct UnsignedAuditEvent<'a> { kind: &'a AuditEventKind, manifest_root_hash: &'a Option, roster_hash: &'a str, + roster: &'a FolderRoster, occurred_at: OffsetDateTime, metadata: &'a BTreeMap, signature: Option, @@ -118,6 +141,7 @@ impl<'a> From<&'a AuditEvent> for UnsignedAuditEvent<'a> { kind: &event.kind, manifest_root_hash: &event.manifest_root_hash, roster_hash: &event.roster_hash, + roster: &event.roster, occurred_at: event.occurred_at, metadata: &event.metadata, signature: None, @@ -128,19 +152,32 @@ impl<'a> From<&'a AuditEvent> for UnsignedAuditEvent<'a> { #[cfg(test)] mod tests { use super::{AuditEvent, AuditEventKind}; + use crate::domain::{FolderRoster, MemberRole, RosterMember}; use uuid::Uuid; #[test] fn rejects_a_changed_signed_event() -> anyhow::Result<()> { let identity = iroh::SecretKey::generate(); - let mut event = AuditEvent::create( - Uuid::new_v4(), + let folder_id = Uuid::new_v4(); + let roster = FolderRoster::create( + folder_id, + "secret".to_owned(), + RosterMember { + device_id: identity.public().to_string(), + display_name: None, + role: MemberRole::Owner, + }, + )? + .sign(&identity)?; + let event = AuditEvent::create( + folder_id, identity.public().to_string(), 1, None, AuditEventKind::ManifestCommitted, Some("manifest".to_owned()), - "roster".to_owned(), + roster.hash()?, + roster, time::OffsetDateTime::UNIX_EPOCH, ); let mut event = event.sign(&identity)?; diff --git a/src/iroh.rs b/src/iroh.rs index 5b51ccd..192d67c 100644 --- a/src/iroh.rs +++ b/src/iroh.rs @@ -37,7 +37,7 @@ use handler::AppaProtocol; /// ALPN for Appa's Iroh control streams. A new value is wire-incompatible. pub const APPA_ALPN: &[u8] = b"appa/sync/4"; const MAX_CONTROL_MESSAGE_BYTES: usize = 16 * 1024 * 1024; -pub(super) const MAX_AUDIT_EVENTS_PER_RESPONSE: usize = 256; +pub(crate) const MAX_AUDIT_EVENTS_PER_RESPONSE: usize = 256; const MAX_CONCURRENT_ANNOUNCEMENTS: usize = 4; const ANNOUNCEMENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); pub(super) const CONTROL_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); diff --git a/src/iroh/tests.rs b/src/iroh/tests.rs index 086fb44..09e6b29 100644 --- a/src/iroh/tests.rs +++ b/src/iroh/tests.rs @@ -4,7 +4,7 @@ use iroh::SecretKey; use tempfile::TempDir; use uuid::Uuid; -use super::{FolderSession, NodeHost}; +use super::{FolderSession, MAX_AUDIT_EVENTS_PER_RESPONSE, NodeHost}; fn test_roster( folder_id: Uuid, @@ -197,6 +197,117 @@ async fn requests_remote_merkle_children_missing_from_a_small_local_manifest() - Ok(()) } +#[tokio::test] +async fn retrieves_a_complete_audit_log_in_pages() -> anyhow::Result<()> { + let source_directory = TempDir::new()?; + let target_directory = TempDir::new()?; + let source = + NodeHost::load_with_lan_discovery(source_directory.path(), SecretKey::generate(), false) + .await?; + let target = + NodeHost::load_with_lan_discovery(target_directory.path(), SecretKey::generate(), false) + .await?; + let folder_id = Uuid::new_v4(); + let capability = "folder-capability".to_owned(); + let audit_identity = SecretKey::generate(); + let roster = signed_owner_roster(folder_id, &audit_identity, &capability)?; + let audit_events = signed_audit_events( + folder_id, + &audit_identity, + &roster, + MAX_AUDIT_EVENTS_PER_RESPONSE + 1, + )?; + source + .register_folder( + FolderSession { + folder_id, + capability: capability.clone(), + active_member_ids: BTreeSet::from([target.endpoint_address().id.to_string()]), + roster, + peer_endpoints: vec![target.endpoint_address()], + audit_events, + }, + crate::domain::Manifest::empty(folder_id), + ) + .await?; + source.wait_until_online().await?; + target.wait_until_online().await?; + + let first_page = target + .request_audit_events( + source.endpoint_address(), + folder_id, + capability.clone(), + None, + Default::default(), + ) + .await?; + let last_sequence = first_page.last().expect("first page has events").sequence; + let second_page = target + .request_audit_events( + source.endpoint_address(), + folder_id, + capability, + None, + std::collections::BTreeMap::from([( + audit_identity.public().to_string(), + last_sequence, + )]), + ) + .await?; + + assert_eq!(first_page.len(), MAX_AUDIT_EVENTS_PER_RESPONSE); + assert_eq!(second_page.len(), 1); + assert_eq!(second_page[0].sequence, last_sequence + 1); + source.shutdown().await?; + target.shutdown().await?; + Ok(()) +} + +fn signed_owner_roster( + folder_id: Uuid, + identity: &SecretKey, + capability: &str, +) -> anyhow::Result { + crate::domain::FolderRoster::create( + folder_id, + capability.to_owned(), + crate::domain::RosterMember { + device_id: identity.public().to_string(), + display_name: None, + role: crate::domain::MemberRole::Owner, + }, + )? + .sign(identity) +} + +fn signed_audit_events( + folder_id: Uuid, + identity: &SecretKey, + roster: &crate::domain::FolderRoster, + count: usize, +) -> anyhow::Result> { + let mut events = Vec::with_capacity(count); + let mut parent_hash = None; + for sequence in 1..=u64::try_from(count)? { + let event = crate::domain::AuditEvent::create( + folder_id, + identity.public().to_string(), + sequence, + parent_hash, + crate::domain::AuditEventKind::ManifestCommitted, + Some(format!("manifest-{sequence}")), + roster.hash()?, + roster.clone(), + time::OffsetDateTime::UNIX_EPOCH, + ) + .sign(identity)?; + parent_hash = Some(event.hash()?); + events.push(event); + } + Ok(events) +} + fn large_manifest(folder_id: Uuid) -> crate::domain::Manifest { let mut manifest = crate::domain::Manifest::empty(folder_id); for number in 0..65 { diff --git a/src/storage/audit.rs b/src/storage/audit.rs index 49818bb..8e43fe0 100644 --- a/src/storage/audit.rs +++ b/src/storage/audit.rs @@ -259,7 +259,7 @@ mod tests { use uuid::Uuid; use crate::{ - domain::{AuditEvent, AuditEventKind}, + domain::{AuditEvent, AuditEventKind, FolderRoster, MemberRole, RosterMember}, storage::{AppPaths, StateStore}, }; @@ -271,6 +271,7 @@ mod tests { )?)?; 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(), @@ -278,7 +279,8 @@ mod tests { None, AuditEventKind::ManifestCommitted, Some("one".to_owned()), - "roster".to_owned(), + roster.hash()?, + roster.clone(), time::OffsetDateTime::UNIX_EPOCH, ); let first = first.sign(&identity)?; @@ -292,7 +294,8 @@ mod tests { parent, AuditEventKind::ManifestCommitted, Some("two".to_owned()), - "roster".to_owned(), + roster.hash()?, + roster, time::OffsetDateTime::UNIX_EPOCH, ); let second = second.sign(&identity)?; @@ -303,4 +306,105 @@ mod tests { 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) + } } diff --git a/src/storage/schema.rs b/src/storage/schema.rs index ba50beb..30e77f3 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -1,6 +1,6 @@ use rusqlite::Connection; -const CURRENT_SCHEMA_VERSION: u32 = 2; +const CURRENT_SCHEMA_VERSION: u32 = 3; struct Migration { version: u32, @@ -9,7 +9,7 @@ struct Migration { const MIGRATIONS: &[Migration] = &[ Migration { - version: CURRENT_SCHEMA_VERSION, + version: 1, statements: &[ "CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY NOT NULL, @@ -73,7 +73,7 @@ const MIGRATIONS: &[Migration] = &[ ], }, Migration { - version: CURRENT_SCHEMA_VERSION, + version: 2, statements: &[ "CREATE TABLE IF NOT EXISTS audit_events ( folder_id TEXT NOT NULL, @@ -99,6 +99,26 @@ const MIGRATIONS: &[Migration] = &[ );", ], }, + Migration { + version: 3, + statements: &[ + "CREATE TABLE IF NOT EXISTS legacy_audit_events ( + folder_id TEXT NOT NULL, + author_device_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + event_hash TEXT NOT NULL, + parent_hash TEXT, + event TEXT NOT NULL, + archived_at TEXT NOT NULL + );", + "INSERT INTO legacy_audit_events (folder_id, author_device_id, sequence, event_hash, parent_hash, event, archived_at) + SELECT folder_id, author_device_id, sequence, event_hash, parent_hash, event, datetime('now') FROM audit_events;", + "INSERT INTO audit_integrity_faults (folder_id, author_device_id, detected_at, detail) + SELECT folder_id, author_device_id, datetime('now'), 'audit protocol v1 events were archived because they lack signed historical roster snapshots' FROM audit_events;", + "DELETE FROM audit_heads;", + "DELETE FROM audit_events;", + ], + }, ]; pub(super) fn initialize(connection: &mut Connection) -> anyhow::Result<()> {