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/rejected_index.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) (limited to 'src/sync/rejected_index.rs') diff --git a/src/sync/rejected_index.rs b/src/sync/rejected_index.rs index 4733d80..f5ffef4 100644 --- a/src/sync/rejected_index.rs +++ b/src/sync/rejected_index.rs @@ -355,7 +355,7 @@ impl RejectedEventsIndex { index } - /// Update metrics with current sizes + /// Update metrics with current sizes (for announcements) fn update_metrics(&self) { if let Some(ref metrics) = self.metrics { metrics.update_hot_cache_size(self.hot_cache.len()); @@ -363,6 +363,14 @@ impl RejectedEventsIndex { } } + /// Update metrics with current sizes (for states) + fn update_states_metrics(&self) { + if let Some(ref metrics) = self.metrics { + metrics.update_states_hot_cache_size(self.hot_cache.len()); + metrics.update_states_cold_index_size(self.cold_index.len()); + } + } + /// Add rejected announcement to both tiers /// /// # Arguments @@ -393,6 +401,36 @@ impl RejectedEventsIndex { self.update_metrics(); } + /// Add rejected state event to both tiers + /// + /// # Arguments + /// + /// * `event` - Full event object (stored in hot cache) + /// * `pubkey` - Author's public key + /// * `identifier` - Repository identifier (d tag) + /// * `reason` - Why the state event was rejected + pub fn add_state( + &self, + event: Event, + pubkey: PublicKey, + identifier: String, + reason: RejectionReason, + ) { + // Add to hot cache (full event) + self.hot_cache.add( + event.clone(), + pubkey, + identifier.clone(), + reason, + ); + + // Add to cold index (metadata only) + self.cold_index.add(event.id, pubkey, identifier, reason); + + // Update metrics (using states metrics) + self.update_states_metrics(); + } + /// Check if event is already rejected (in either tier) pub fn contains(&self, event_id: &EventId) -> bool { self.hot_cache.contains(event_id) || self.cold_index.contains(event_id) @@ -442,7 +480,51 @@ impl RejectedEventsIndex { (removed, events) } - /// Clean up expired entries from both tiers + /// Invalidate state events and get events for immediate re-processing + /// + /// This is called when an announcement is accepted that authorizes state events. + /// It removes the cold index entries (so they can be re-fetched on next sync) and + /// returns any events still in the hot cache for immediate re-processing. + /// + /// # Returns + /// + /// Tuple of (number of cold index entries removed, events from hot cache) + pub fn invalidate_and_get_state_events( + &self, + maintainer_pubkey: &PublicKey, + identifier: &str, + ) -> (usize, Vec) { + // Remove from cold index (prevents re-fetch) + let removed = self + .cold_index + .invalidate_maintainer_announcements(maintainer_pubkey, identifier); + + // Get from hot cache (for immediate re-processing) + let events = self + .hot_cache + .get_maintainer_events(maintainer_pubkey, identifier); + + // Track metrics (using states metrics) + if let Some(ref metrics) = self.metrics { + if removed > 0 { + metrics.record_states_invalidation(removed); + } + if events.is_empty() { + metrics.record_states_hot_cache_miss(); + } else { + for _ in &events { + metrics.record_states_hot_cache_hit(); + } + } + } + + // Update size metrics (using states metrics) + self.update_states_metrics(); + + (removed, events) + } + + /// Clean up expired entries from both tiers (for announcements) /// /// Returns tuple of (hot cache expired, cold index expired) pub fn cleanup_expired(&self) -> (usize, usize) { @@ -465,6 +547,29 @@ impl RejectedEventsIndex { (hot_expired, cold_expired) } + /// Clean up expired entries from both tiers (for states) + /// + /// Returns tuple of (hot cache expired, cold index expired) + pub fn cleanup_states_expired(&self) -> (usize, usize) { + let hot_expired = self.hot_cache.cleanup_expired(); + let cold_expired = self.cold_index.cleanup_expired(); + + // Track metrics (using states metrics) + if let Some(ref metrics) = self.metrics { + if hot_expired > 0 { + metrics.record_states_hot_cache_expired(hot_expired); + } + if cold_expired > 0 { + metrics.record_states_cold_index_expired(cold_expired); + } + } + + // Update size metrics (using states metrics) + self.update_states_metrics(); + + (hot_expired, cold_expired) + } + /// Get current number of entries in hot cache pub fn hot_cache_len(&self) -> usize { self.hot_cache.len() -- cgit v1.2.3