diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-09 17:04:06 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-09 17:04:06 +0000 |
| commit | 5ecd8d6a434f97da94daef2f59166086fbaf5a6b (patch) | |
| tree | 54c7d3b953a6b1aedd1db6b9a719e18131659df5 /src/nostr | |
| parent | 895359aeb6746b98ff82944e4fca503f4a6e5439 (diff) | |
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
Diffstat (limited to 'src/nostr')
| -rw-r--r-- | src/nostr/builder.rs | 72 | ||||
| -rw-r--r-- | src/nostr/policy/state.rs | 40 |
2 files changed, 112 insertions, 0 deletions
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 { | |||
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | tracing::debug!("Accepted repository announcement: {}", event_id_str); | 104 | tracing::debug!("Accepted repository announcement: {}", event_id_str); |
| 105 | |||
| 106 | // Check purgatory for state events that might now be authorized | ||
| 107 | self.check_purgatory_state_events_for_identifier(&announcement.identifier) | ||
| 108 | .await; | ||
| 109 | |||
| 105 | WritePolicyResult::Accept | 110 | WritePolicyResult::Accept |
| 106 | } | 111 | } |
| 107 | Err(e) => { | 112 | Err(e) => { |
| @@ -125,6 +130,11 @@ impl Nip34WritePolicy { | |||
| 125 | announcement.identifier | 130 | announcement.identifier |
| 126 | ); | 131 | ); |
| 127 | // Don't create bare repository for external announcements | 132 | // Don't create bare repository for external announcements |
| 133 | |||
| 134 | // Check purgatory for state events that might now be authorized | ||
| 135 | self.check_purgatory_state_events_for_identifier(&announcement.identifier) | ||
| 136 | .await; | ||
| 137 | |||
| 128 | WritePolicyResult::Accept | 138 | WritePolicyResult::Accept |
| 129 | } | 139 | } |
| 130 | Err(e) => { | 140 | Err(e) => { |
| @@ -304,6 +314,68 @@ impl Nip34WritePolicy { | |||
| 304 | } | 314 | } |
| 305 | } | 315 | } |
| 306 | 316 | ||
| 317 | /// Check purgatory for state events that might now be authorized by a new announcement | ||
| 318 | /// | ||
| 319 | /// When an announcement is accepted, state events in purgatory that were previously | ||
| 320 | /// rejected due to missing announcements might now be authorized. This method: | ||
| 321 | /// 1. Finds all state events in purgatory for the identifier | ||
| 322 | /// 2. Re-evaluates authorization for each event | ||
| 323 | /// 3. Processes authorized events (releases from purgatory) | ||
| 324 | /// 4. Keeps unauthorized events in purgatory (will expire naturally) | ||
| 325 | async fn check_purgatory_state_events_for_identifier(&self, identifier: &str) { | ||
| 326 | let state_events = self.ctx.purgatory.find_state(identifier); | ||
| 327 | |||
| 328 | if state_events.is_empty() { | ||
| 329 | return; | ||
| 330 | } | ||
| 331 | |||
| 332 | tracing::debug!( | ||
| 333 | identifier = %identifier, | ||
| 334 | count = state_events.len(), | ||
| 335 | "Checking purgatory state events after announcement acceptance" | ||
| 336 | ); | ||
| 337 | |||
| 338 | for entry in state_events { | ||
| 339 | // Re-evaluate authorization with the new announcement | ||
| 340 | match self.state_policy.process_state_event(&entry.event, false).await { | ||
| 341 | Ok(WritePolicyResult::Accept) => { | ||
| 342 | tracing::info!( | ||
| 343 | event_id = %entry.event.id, | ||
| 344 | identifier = %identifier, | ||
| 345 | "State event in purgatory now authorized, will be processed" | ||
| 346 | ); | ||
| 347 | // Event will be automatically removed from purgatory by process_state_event | ||
| 348 | // and broadcast to subscribers | ||
| 349 | } | ||
| 350 | Ok(WritePolicyResult::Reject { message, .. }) => { | ||
| 351 | if message.contains("not authorized") { | ||
| 352 | tracing::debug!( | ||
| 353 | event_id = %entry.event.id, | ||
| 354 | identifier = %identifier, | ||
| 355 | "State event in purgatory still not authorized, keeping in purgatory" | ||
| 356 | ); | ||
| 357 | // Keep in purgatory - will expire naturally after 30 minutes | ||
| 358 | } else { | ||
| 359 | tracing::debug!( | ||
| 360 | event_id = %entry.event.id, | ||
| 361 | identifier = %identifier, | ||
| 362 | reason = %message, | ||
| 363 | "State event in purgatory rejected for other reason" | ||
| 364 | ); | ||
| 365 | } | ||
| 366 | } | ||
| 367 | Err(e) => { | ||
| 368 | tracing::warn!( | ||
| 369 | event_id = %entry.event.id, | ||
| 370 | identifier = %identifier, | ||
| 371 | error = %e, | ||
| 372 | "Error re-evaluating state event in purgatory" | ||
| 373 | ); | ||
| 374 | } | ||
| 375 | } | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 307 | /// Handle events that must reference accepted repositories or events | 379 | /// Handle events that must reference accepted repositories or events |
| 308 | async fn handle_related_event(&self, event: &Event, event_type: &str) -> WritePolicyResult { | 380 | async fn handle_related_event(&self, event: &Event, event_type: &str) -> WritePolicyResult { |
| 309 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | 381 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); |
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index acb76a3..d26b5ec 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs | |||
| @@ -78,6 +78,46 @@ impl StatePolicy { | |||
| 78 | // Get all repositories and state events from db with identifier | 78 | // Get all repositories and state events from db with identifier |
| 79 | let db_repo_data = fetch_repository_data(&self.ctx.database, &state.identifier).await?; | 79 | let db_repo_data = fetch_repository_data(&self.ctx.database, &state.identifier).await?; |
| 80 | 80 | ||
| 81 | // CRITICAL: Check if author is authorized via maintainer set | ||
| 82 | // State events MUST be rejected if author is not in maintainer set of any accepted announcement | ||
| 83 | if db_repo_data.announcements.is_empty() { | ||
| 84 | tracing::warn!( | ||
| 85 | event_id = %event.id, | ||
| 86 | identifier = %state.identifier, | ||
| 87 | author = %event.pubkey.to_hex(), | ||
| 88 | "Rejecting state event: no announcement exists for this repository" | ||
| 89 | ); | ||
| 90 | return Ok(WritePolicyResult::Reject { | ||
| 91 | status: false, | ||
| 92 | message: "invalid: no announcement exists for this repository".into(), | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | let authorized_owners = | ||
| 97 | crate::git::authorization::pubkey_authorised_for_repo_owners(&event.pubkey, &db_repo_data); | ||
| 98 | |||
| 99 | if authorized_owners.is_empty() { | ||
| 100 | tracing::warn!( | ||
| 101 | event_id = %event.id, | ||
| 102 | identifier = %state.identifier, | ||
| 103 | author = %event.pubkey.to_hex(), | ||
| 104 | announcements_count = db_repo_data.announcements.len(), | ||
| 105 | "Rejecting state event: author not in maintainer set of any announcement" | ||
| 106 | ); | ||
| 107 | return Ok(WritePolicyResult::Reject { | ||
| 108 | status: false, | ||
| 109 | message: "invalid: author not authorized for this repository".into(), | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | |||
| 113 | tracing::debug!( | ||
| 114 | event_id = %event.id, | ||
| 115 | identifier = %state.identifier, | ||
| 116 | author = %event.pubkey.to_hex(), | ||
| 117 | authorized_for_owners = ?authorized_owners, | ||
| 118 | "State event author authorized via maintainer set" | ||
| 119 | ); | ||
| 120 | |||
| 81 | // Duplicate check in db | 121 | // Duplicate check in db |
| 82 | if db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)) { | 122 | if db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)) { |
| 83 | tracing::debug!("processed state event duplicate (in db): {}", event.id); | 123 | tracing::debug!("processed state event duplicate (in db): {}", event.id); |