use std::collections::BTreeMap; use anyhow::Context; use iroh::{EndpointId, SecretKey, Signature}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use super::{DeviceId, FolderId, FolderRoster, MemberRole, canonical_json_bytes}; pub const AUDIT_PROTOCOL_VERSION: u16 = 2; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuditEventKind { ManifestCommitted, RosterUpdated, OwnerIdentityRestored, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct AuditEvent { pub protocol_version: u16, pub folder_id: FolderId, pub author_device_id: DeviceId, pub sequence: u64, pub parent_hash: Option, 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, } impl AuditEvent { pub fn create( folder_id: FolderId, author_device_id: DeviceId, sequence: u64, parent_hash: Option, kind: AuditEventKind, manifest_root_hash: Option, roster_hash: String, roster: FolderRoster, occurred_at: OffsetDateTime, ) -> Self { Self { protocol_version: AUDIT_PROTOCOL_VERSION, folder_id, author_device_id, sequence, parent_hash, kind, manifest_root_hash, roster_hash, roster, occurred_at, metadata: BTreeMap::new(), signature: None, } } pub fn sign(mut self, identity: &SecretKey) -> anyhow::Result { if identity.public().to_string() != self.author_device_id { anyhow::bail!("audit event author does not match its signing identity"); } self.signature = Some(identity.sign(&self.signing_bytes()?)); Ok(self) } pub fn validate(&self) -> anyhow::Result<()> { if self.protocol_version != AUDIT_PROTOCOL_VERSION { anyhow::bail!("unsupported audit protocol version"); } 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 .as_ref() .ok_or_else(|| anyhow::anyhow!("audit event is unsigned"))?; author .verify(&self.signing_bytes()?, signature) .context("audit event signature is invalid") } pub fn hash(&self) -> anyhow::Result { Ok(blake3::hash(&self.signing_bytes()?).to_hex().to_string()) } fn signing_bytes(&self) -> anyhow::Result> { canonical_json_bytes(&UnsignedAuditEvent::from(self)) } } #[derive(Serialize)] struct UnsignedAuditEvent<'a> { protocol_version: u16, folder_id: FolderId, author_device_id: &'a str, sequence: u64, parent_hash: &'a Option, kind: &'a AuditEventKind, manifest_root_hash: &'a Option, roster_hash: &'a str, roster: &'a FolderRoster, occurred_at: OffsetDateTime, metadata: &'a BTreeMap, signature: Option, } impl<'a> From<&'a AuditEvent> for UnsignedAuditEvent<'a> { fn from(event: &'a AuditEvent) -> Self { Self { protocol_version: event.protocol_version, folder_id: event.folder_id, author_device_id: &event.author_device_id, sequence: event.sequence, parent_hash: &event.parent_hash, 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, } } } #[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 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.hash()?, roster, time::OffsetDateTime::UNIX_EPOCH, ); let mut event = event.sign(&identity)?; event.sequence = 2; assert!(event.validate().is_err()); Ok(()) } }