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/nostr/builder.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) (limited to 'src/nostr/builder.rs') diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 939ccef..acaac71 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -102,6 +102,11 @@ impl Nip34WritePolicy { } tracing::debug!("Accepted repository announcement: {}", event_id_str); + + // Check purgatory for state events that might now be authorized + self.check_purgatory_state_events_for_identifier(&announcement.identifier) + .await; + WritePolicyResult::Accept } Err(e) => { @@ -125,6 +130,11 @@ impl Nip34WritePolicy { announcement.identifier ); // Don't create bare repository for external announcements + + // Check purgatory for state events that might now be authorized + self.check_purgatory_state_events_for_identifier(&announcement.identifier) + .await; + WritePolicyResult::Accept } Err(e) => { @@ -304,6 +314,68 @@ impl Nip34WritePolicy { } } + /// Check purgatory for state events that might now be authorized by a new announcement + /// + /// When an announcement is accepted, state events in purgatory that were previously + /// rejected due to missing announcements might now be authorized. This method: + /// 1. Finds all state events in purgatory for the identifier + /// 2. Re-evaluates authorization for each event + /// 3. Processes authorized events (releases from purgatory) + /// 4. Keeps unauthorized events in purgatory (will expire naturally) + async fn check_purgatory_state_events_for_identifier(&self, identifier: &str) { + let state_events = self.ctx.purgatory.find_state(identifier); + + if state_events.is_empty() { + return; + } + + tracing::debug!( + identifier = %identifier, + count = state_events.len(), + "Checking purgatory state events after announcement acceptance" + ); + + for entry in state_events { + // Re-evaluate authorization with the new announcement + match self.state_policy.process_state_event(&entry.event, false).await { + Ok(WritePolicyResult::Accept) => { + tracing::info!( + event_id = %entry.event.id, + identifier = %identifier, + "State event in purgatory now authorized, will be processed" + ); + // Event will be automatically removed from purgatory by process_state_event + // and broadcast to subscribers + } + Ok(WritePolicyResult::Reject { message, .. }) => { + if message.contains("not authorized") { + tracing::debug!( + event_id = %entry.event.id, + identifier = %identifier, + "State event in purgatory still not authorized, keeping in purgatory" + ); + // Keep in purgatory - will expire naturally after 30 minutes + } else { + tracing::debug!( + event_id = %entry.event.id, + identifier = %identifier, + reason = %message, + "State event in purgatory rejected for other reason" + ); + } + } + Err(e) => { + tracing::warn!( + event_id = %entry.event.id, + identifier = %identifier, + error = %e, + "Error re-evaluating state event in purgatory" + ); + } + } + } + } + /// Handle events that must reference accepted repositories or events async fn handle_related_event(&self, event: &Event, event_type: &str) -> WritePolicyResult { let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); -- cgit v1.2.3