From afcc10411e5b87d4640417cca69fae90e4dd5f56 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 3 Aug 2026 08:36:19 -0400 Subject: [PATCH] Make signed timestamps explicit --- src/app.rs | 27 +++++++++++++++++++----- src/app/manifest.rs | 8 ++++--- src/app/materialize.rs | 2 -- src/app/sync.rs | 10 +++++++-- src/app/tests.rs | 4 ++++ src/domain/audit.rs | 4 +++- src/iroh/handler.rs | 2 +- src/protocol.rs | 47 ++++++++++++++++++++++++++++++++++++++---- src/storage/audit.rs | 2 ++ 9 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index 919d34b..cf1b5a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,6 +32,7 @@ pub(super) struct SyncContext<'a> { pub(super) folder: &'a FolderConfig, pub(super) node: &'a NodeHost, pub(super) state_store: &'a StateStore, + pub(super) now: OffsetDateTime, } pub struct AppaService { @@ -123,7 +124,7 @@ impl AppaService { }; let mut roster = FolderRoster::create(folder.id, folder.capability.clone(), owner)?; roster.sign(&owner_identity)?; - let audit_event = self.roster_audit_event(&roster)?; + let audit_event = self.roster_audit_event(&roster, OffsetDateTime::now_utc())?; self.state_store .save_roster_with_audit(&roster, &audit_event) } @@ -173,6 +174,16 @@ impl AppaService { &self, folder_path: &Path, node: &NodeHost, + ) -> AppResult { + self.create_invite_with_node_at(folder_path, node, OffsetDateTime::now_utc()) + .await + } + + async fn create_invite_with_node_at( + &self, + folder_path: &Path, + node: &NodeHost, + now: OffsetDateTime, ) -> AppResult { let folder = self.require_folder(folder_path)?; let roster = self @@ -189,7 +200,7 @@ impl AppaService { inviter_endpoint: node.endpoint_address(), capability: folder.capability.clone(), roster, - expires_at: OffsetDateTime::now_utc() + INVITE_TTL, + expires_at: now + INVITE_TTL, signature: None, }; invite.sign(&self.paths.load_identity()?)?; @@ -264,6 +275,7 @@ impl AppaService { folder: &folder, node: &node, state_store: &self.state_store, + now: OffsetDateTime::now_utc(), }; let result = restore_manifest(&context, ¤t_manifest, &historical_manifest).await; node.shutdown().await?; @@ -309,7 +321,7 @@ impl AppaService { roster.remove_member(device_id)?; roster.rotate_capability(self.state_store.new_capability()); roster.sign(&self.paths.load_identity()?)?; - let audit_event = self.roster_audit_event(&roster)?; + let audit_event = self.roster_audit_event(&roster, OffsetDateTime::now_utc())?; self.state_store.replace_capability_and_roster_with_audit( folder.id, &roster, @@ -483,12 +495,16 @@ impl AppaService { role: MemberRole::Member, })?; roster.sign(&self.paths.load_identity()?)?; - let audit_event = self.roster_audit_event(&roster)?; + let audit_event = self.roster_audit_event(&roster, OffsetDateTime::now_utc())?; self.state_store .save_roster_with_audit(&roster, &audit_event) } - fn roster_audit_event(&self, roster: &FolderRoster) -> AppResult { + fn roster_audit_event( + &self, + roster: &FolderRoster, + now: OffsetDateTime, + ) -> AppResult { let identity = self.paths.load_identity()?; let author_device_id = identity.public().to_string(); if roster.owner_device_id != author_device_id { @@ -506,6 +522,7 @@ impl AppaService { AuditEventKind::RosterUpdated, Some(manifest_root_hash(&manifest)?), roster.hash()?, + now, ); event.sign(&identity)?; Ok(event) diff --git a/src/app/manifest.rs b/src/app/manifest.rs index 73fd5ff..e2d1a14 100644 --- a/src/app/manifest.rs +++ b/src/app/manifest.rs @@ -76,10 +76,11 @@ where for directory_path in collected_entries.directories { directory_count += 1; let path = relative_path(&context.folder.path, &directory_path)?; + let modified_at = OffsetDateTime::from(fs::metadata(&directory_path)?.modified()?); observed_paths.insert(path.clone()); let entry = match previous.entries.get(&path) { Some(existing) if existing.kind == EntryKind::Directory => existing.clone(), - previous => changed_directory_entry(&path, previous, &device_id, context)?, + previous => changed_directory_entry(&path, modified_at, previous, &device_id, context)?, }; entries.insert(path, entry); } @@ -250,6 +251,7 @@ struct FileRevisionInput<'a> { fn changed_directory_entry( path: &str, + modified_at: OffsetDateTime, previous: Option<&Entry>, device_id: &DeviceId, context: &SyncContext<'_>, @@ -268,7 +270,7 @@ fn changed_directory_entry( kind: EntryKind::Directory, blob_hash: None, size_bytes: None, - modified_at: OffsetDateTime::now_utc(), + modified_at, clock, author_device_id: device_id.clone(), }) @@ -315,7 +317,7 @@ pub(super) fn deleted_entry( kind: EntryKind::Deleted, blob_hash: None, size_bytes: None, - modified_at: OffsetDateTime::now_utc(), + modified_at: context.now, clock, author_device_id: device_id.clone(), }) diff --git a/src/app/materialize.rs b/src/app/materialize.rs index de628f8..6306338 100644 --- a/src/app/materialize.rs +++ b/src/app/materialize.rs @@ -7,7 +7,6 @@ use std::{ use anyhow::Context; use futures_util::{StreamExt, stream}; -use time::OffsetDateTime; use crate::{ app::{MAX_CONCURRENT_BLOB_TRANSFERS, SyncContext, manifest::deleted_entry}, @@ -111,7 +110,6 @@ fn restored_entry( .next_counter(context.folder.id, device_id)?, ); restored.clock = clock; - restored.modified_at = OffsetDateTime::now_utc(); restored.author_device_id = device_id.clone(); Ok(Some(restored)) } diff --git a/src/app/sync.rs b/src/app/sync.rs index b2503ab..53ea244 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -33,6 +33,7 @@ impl AppaService { folder, node, state_store: &self.state_store, + now: time::OffsetDateTime::now_utc(), }; recover_pending_materialization(&context).await?; self.save_lan_discovered_peers(folder, node)?; @@ -148,7 +149,7 @@ impl AppaService { node: &NodeHost, manifest: &crate::domain::Manifest, ) -> AppResult<()> { - let audit_event = self.manifest_audit_event(manifest)?; + let audit_event = self.manifest_audit_event(manifest, time::OffsetDateTime::now_utc())?; self.state_store .save_manifest_with_audit(manifest, &audit_event)?; node.publish_audit_events( @@ -159,7 +160,11 @@ impl AppaService { node.publish_manifest(manifest.clone()).await } - fn manifest_audit_event(&self, manifest: &crate::domain::Manifest) -> AppResult { + fn manifest_audit_event( + &self, + manifest: &crate::domain::Manifest, + now: time::OffsetDateTime, + ) -> AppResult { let identity = self.paths.load_identity()?; let roster = self.load_roster(manifest.folder_id)?; let author_device_id = identity.public().to_string(); @@ -177,6 +182,7 @@ impl AppaService { AuditEventKind::ManifestCommitted, Some(manifest_root_hash(manifest)?), roster.hash()?, + now, ); event.sign(&identity)?; Ok(event) diff --git a/src/app/tests.rs b/src/app/tests.rs index 54599d9..65d14e3 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -118,6 +118,7 @@ async fn publishes_imported_files_in_bounded_stages() -> anyhow::Result<()> { folder: &folder, node: &node, state_store: &appa.state_store, + now: OffsetDateTime::UNIX_EPOCH, }; let stages = Arc::new(Mutex::new(Vec::new())); let captured_stages = Arc::clone(&stages); @@ -170,6 +171,7 @@ async fn synchronizes_and_deletes_a_file_between_two_devices() -> anyhow::Result folder: &source_config, node: &source_node, state_store: &source.state_store, + now: OffsetDateTime::UNIX_EPOCH, }; let source_manifest = build_manifest(&source_context).await?.manifest; source_node @@ -280,6 +282,7 @@ async fn preserves_both_versions_after_offline_edits() -> anyhow::Result<()> { folder: &source_config, node: &source_node, state_store: &source.state_store, + now: OffsetDateTime::UNIX_EPOCH, }; let source_manifest = build_manifest(&source_context).await?.manifest; source_node @@ -328,6 +331,7 @@ async fn preserves_both_versions_after_offline_edits() -> anyhow::Result<()> { folder: &target_config, node: &target_node, state_store: &target.state_store, + now: OffsetDateTime::UNIX_EPOCH, }; let target_manifest = build_manifest(&target_context).await?.manifest; target_node diff --git a/src/domain/audit.rs b/src/domain/audit.rs index 672e34b..0700ffa 100644 --- a/src/domain/audit.rs +++ b/src/domain/audit.rs @@ -41,6 +41,7 @@ impl AuditEvent { kind: AuditEventKind, manifest_root_hash: Option, roster_hash: String, + occurred_at: OffsetDateTime, ) -> Self { Self { protocol_version: AUDIT_PROTOCOL_VERSION, @@ -51,7 +52,7 @@ impl AuditEvent { kind, manifest_root_hash, roster_hash, - occurred_at: OffsetDateTime::now_utc(), + occurred_at, metadata: BTreeMap::new(), signature: None, } @@ -140,6 +141,7 @@ mod tests { AuditEventKind::ManifestCommitted, Some("manifest".to_owned()), "roster".to_owned(), + time::OffsetDateTime::UNIX_EPOCH, ); event.sign(&identity)?; event.sequence = 2; diff --git a/src/iroh/handler.rs b/src/iroh/handler.rs index a70efd8..5e18c7d 100644 --- a/src/iroh/handler.rs +++ b/src/iroh/handler.rs @@ -180,7 +180,7 @@ impl AppaProtocol { } if !is_active_member && !invite.is_some_and(|invite| { - validate_invite(invite).is_ok() + validate_invite(invite, time::OffsetDateTime::now_utc()).is_ok() && invite.folder_id == folder_id && invite.capability == folder.capability }) diff --git a/src/protocol.rs b/src/protocol.rs index 5a431ae..88c37ab 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -149,16 +149,20 @@ pub fn encode_invite(invite: &Invite) -> anyhow::Result { } pub fn decode_invite(ticket: &str) -> anyhow::Result { + decode_invite_at(ticket, OffsetDateTime::now_utc()) +} + +pub fn decode_invite_at(ticket: &str, now: OffsetDateTime) -> anyhow::Result { let encoded_payload = ticket .strip_prefix(INVITE_SCHEME) .ok_or_else(|| anyhow::anyhow!("Appa invitation must start with appa://"))?; let payload = URL_SAFE_NO_PAD.decode(encoded_payload)?; let invite: Invite = serde_json::from_slice(&payload)?; - validate_invite(&invite)?; + validate_invite(&invite, now)?; Ok(invite) } -pub fn validate_invite(invite: &Invite) -> anyhow::Result<()> { +pub fn validate_invite(invite: &Invite, now: OffsetDateTime) -> anyhow::Result<()> { if invite.protocol_version != INVITATION_PROTOCOL_VERSION { anyhow::bail!( "invitation uses unsupported Appa protocol version {} (expected {})", @@ -166,7 +170,7 @@ pub fn validate_invite(invite: &Invite) -> anyhow::Result<()> { INVITATION_PROTOCOL_VERSION ); } - if invite.expires_at < OffsetDateTime::now_utc() { + if invite.expires_at < now { anyhow::bail!("invitation has expired"); } if invite.roster.folder_id != invite.folder_id { @@ -187,7 +191,8 @@ mod tests { use uuid::Uuid; use super::{ - INVITATION_PROTOCOL_VERSION, Invite, ManifestSummary, decode_invite, encode_invite, + INVITATION_PROTOCOL_VERSION, Invite, ManifestSummary, decode_invite, decode_invite_at, + encode_invite, }; #[test] @@ -231,6 +236,40 @@ mod tests { Ok(()) } + #[test] + fn validates_an_invitation_against_the_supplied_time() -> anyhow::Result<()> { + let identity = iroh::SecretKey::generate(); + let endpoint = iroh::EndpointAddr::new(identity.public()); + let mut roster = FolderRoster::create( + Uuid::new_v4(), + "secret".to_owned(), + RosterMember { + device_id: endpoint.id.to_string(), + display_name: None, + role: MemberRole::Owner, + }, + )?; + roster.sign(&identity)?; + let expires_at = time::OffsetDateTime::UNIX_EPOCH + Duration::hours(1); + let mut invite = Invite { + protocol_version: INVITATION_PROTOCOL_VERSION, + folder_id: roster.folder_id, + folder_name: "notes".to_owned(), + inviter_device_id: endpoint.id.to_string(), + inviter_endpoint: endpoint, + capability: "secret".to_owned(), + roster, + expires_at, + signature: None, + }; + invite.sign(&identity)?; + let ticket = encode_invite(&invite)?; + + assert!(decode_invite_at(&ticket, expires_at - Duration::seconds(1)).is_ok()); + assert!(decode_invite_at(&ticket, expires_at + Duration::seconds(1)).is_err()); + Ok(()) + } + #[test] fn manifest_summaries_change_with_manifest_contents() -> anyhow::Result<()> { let folder_id = Uuid::new_v4(); diff --git a/src/storage/audit.rs b/src/storage/audit.rs index f839a61..2b92957 100644 --- a/src/storage/audit.rs +++ b/src/storage/audit.rs @@ -279,6 +279,7 @@ mod tests { AuditEventKind::ManifestCommitted, Some("one".to_owned()), "roster".to_owned(), + time::OffsetDateTime::UNIX_EPOCH, ); first.sign(&identity)?; store.append_audit_event(&first)?; @@ -292,6 +293,7 @@ mod tests { AuditEventKind::ManifestCommitted, Some("two".to_owned()), "roster".to_owned(), + time::OffsetDateTime::UNIX_EPOCH, ); second.sign(&identity)?; store.append_audit_event(&second)?; -- 2.51.2