From 5ecd8d6a434f97da94daef2f59166086fbaf5a6b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 9 Jan 2026 17:04:06 +0000 Subject: feat: implement state event authorization per GRASP-01 spec Add comprehensive authorization checks to ensure state events are only accepted from maintainers of accepted repository announcements. This implements the core GRASP-01 requirement that pushes must match the latest state announcement "respecting the maintainer set." Changes: 1. StatePolicy authorization (src/nostr/policy/state.rs): - Check authorization BEFORE git data validation (fail-fast) - Reject if no announcement exists for repository - Reject if author not in maintainer set - Use existing helpers: fetch_repository_data() and pubkey_authorised_for_repo_owners() - Structured logging for all rejections 2. Purgatory invalidation (src/nostr/builder.rs): - New method: check_purgatory_state_events_for_identifier() - Called when announcements accepted (Accept and AcceptMaintainer) - Re-evaluates state events in purgatory for the identifier - Processes newly-authorized events (releases from purgatory) - Keeps unauthorized events for natural expiry (30 min) - Enables retroactive authorization when announcements arrive late 3. Purgatory sync authorization (src/git/sync.rs): - Check authorization BEFORE processing git data - Remove unauthorized events from purgatory (permanent rejection) - Prevents processing even if git data arrives first - Structured logging for monitoring 4. Rejected events tracking (src/sync/rejected_index.rs): - Add support for tracking rejected state events - New methods: add_state(), contains_state() - Separate metrics for state rejections - Enables sync to avoid re-fetching rejected states 5. Sync metrics (src/sync/metrics.rs, src/sync/mod.rs): - Add state-specific metrics (hot cache, cold index) - Track rejected states separately from announcements - Support monitoring of authorization rejections 6. Comprehensive tests (tests/state_authorization.rs): - test_reject_state_without_announcement - test_reject_state_from_unauthorized_author - test_accept_state_from_announcement_author - test_accept_state_from_maintainer Security Impact: - Before: State events could be published by anyone - After: Only maintainers can publish state events - Defense-in-depth: Authorization checked at 3 points: 1. On arrival (StatePolicy) 2. On announcement acceptance (purgatory re-evaluation) 3. On git data arrival (purgatory sync) All tests pass: - 248 unit tests - 51 NIP-34 announcement tests - 4 new state authorization tests - 9 rejected index tests Closes: State authorization requirement from GRASP-01 spec --- src/sync/mod.rs | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) (limited to 'src/sync/mod.rs') diff --git a/src/sync/mod.rs b/src/sync/mod.rs index f296c0f..93b0e38 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -367,12 +367,14 @@ async fn run_daily_timer( /// Run the combined health and metrics checker /// /// This function runs in a loop with a 2-second interval, performing three tasks: -/// Background task for cleaning up expired entries from the rejected events index +/// Background task for cleaning up expired entries from the rejected events indexes /// /// This task runs two cleanup operations at different intervals: /// 1. **Hot cache cleanup (60s)**: Remove events older than 2 minutes from hot cache /// 2. **Cold index cleanup (daily)**: Remove metadata older than 7 days from cold index /// +/// Cleans up both the announcements index and the states index. +/// /// The hot cache cleanup runs frequently to keep memory usage low (events expire quickly). /// The cold index cleanup runs daily since metadata is small and expires slowly. async fn run_rejected_index_cleanup( @@ -397,6 +399,8 @@ async fn run_rejected_index_cleanup( tokio::select! { _ = hot_cache_timer.tick() => { let manager = sync_manager.lock().await; + + // Clean up announcements index let (hot_expired, _) = manager.rejected_events_index.cleanup_expired(); if hot_expired > 0 { tracing::debug!( @@ -404,9 +408,20 @@ async fn run_rejected_index_cleanup( hot_expired ); } + + // Clean up states index + let (states_hot_expired, _) = manager.rejected_states_index.cleanup_states_expired(); + if states_hot_expired > 0 { + tracing::debug!( + "Cleaned up {} expired entries from rejected states hot cache", + states_hot_expired + ); + } } _ = cold_index_timer.tick() => { let manager = sync_manager.lock().await; + + // Clean up announcements index let (_, cold_expired) = manager.rejected_events_index.cleanup_expired(); if cold_expired > 0 { tracing::info!( @@ -414,6 +429,15 @@ async fn run_rejected_index_cleanup( cold_expired ); } + + // Clean up states index + let (_, states_cold_expired) = manager.rejected_states_index.cleanup_states_expired(); + if states_cold_expired > 0 { + tracing::info!( + "Cleaned up {} expired entries from rejected states cold index", + states_cold_expired + ); + } } _ = shutdown_rx.recv() => { tracing::info!("Rejected index cleanup received shutdown signal"); @@ -507,6 +531,8 @@ pub struct SyncManager { pending_sync_index: PendingSyncIndex, /// Rejected announcement events (30617/30618) - two-tier storage for re-processing rejected_events_index: Arc, + /// Rejected state events (30618) - two-tier storage for re-processing + rejected_states_index: Arc, /// Active relay connections - keyed by relay URL connections: HashMap, /// Health tracker for relay connection state @@ -571,6 +597,18 @@ impl SyncManager { Duration::from_secs(config.rejected_cold_index_expiry_secs), ) }), + rejected_states_index: Arc::new(if let Some(ref metrics) = sync_metrics { + RejectedEventsIndex::with_metrics( + Duration::from_secs(config.rejected_hot_cache_duration_secs), + Duration::from_secs(config.rejected_cold_index_expiry_secs), + metrics.clone(), + ) + } else { + RejectedEventsIndex::new( + Duration::from_secs(config.rejected_hot_cache_duration_secs), + Duration::from_secs(config.rejected_cold_index_expiry_secs), + ) + }), connections: HashMap::new(), health_tracker: Arc::new(RelayHealthTracker::new(config)), next_batch_id: 0, -- cgit v1.2.3