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 --- tests/state_authorization.rs | 280 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 tests/state_authorization.rs (limited to 'tests') diff --git a/tests/state_authorization.rs b/tests/state_authorization.rs new file mode 100644 index 0000000..a5dfa2d --- /dev/null +++ b/tests/state_authorization.rs @@ -0,0 +1,280 @@ +//! Tests for state event authorization +//! +//! Verifies that state events are properly rejected when: +//! 1. No announcement exists for the repository +//! 2. Author is not in the maintainer set + +mod common; + +use common::relay::TestRelay; +use nostr_sdk::prelude::*; + +#[tokio::test] +async fn test_reject_state_without_announcement() { + // Start test relay + let relay = TestRelay::start().await; + + // Create test keypair + let keys = Keys::generate(); + + // Create a state event without any announcement + let state_event = EventBuilder::new( + Kind::RepoState, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]), + ]) + .sign_with_keys(&keys) + .unwrap(); + + // Connect to relay + let client = Client::default(); + client.add_relay(relay.url()).await.unwrap(); + client.connect().await; + + // Try to send state event + let result = client.send_event(&state_event).await; + + // Should be rejected + match result { + Ok(output) => { + assert!( + !output.success.is_empty() || !output.failed.is_empty(), + "Event should be processed" + ); + // Check if any relay rejected it + let rejected = output.failed.values().any(|err| { + err.to_string().contains("no announcement exists") + }); + assert!(rejected, "Event should be rejected due to missing announcement"); + } + Err(e) => { + // Also acceptable - relay rejected the event + assert!( + e.to_string().contains("no announcement exists") || + e.to_string().contains("rejected"), + "Error should indicate missing announcement: {}", + e + ); + } + } + + relay.stop().await; +} + +#[tokio::test] +async fn test_reject_state_from_unauthorized_author() { + // Start test relay + let relay = TestRelay::start().await; + + // Create two keypairs: one for announcement, one for unauthorized state + let announcement_keys = Keys::generate(); + let unauthorized_keys = Keys::generate(); + + // Create announcement + let announcement = EventBuilder::new( + Kind::GitRepoAnnouncement, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]), + Tag::custom(TagKind::custom("relays"), [relay.url()]), + ]) + .sign_with_keys(&announcement_keys) + .unwrap(); + + // Connect to relay + let client = Client::default(); + client.add_relay(relay.url()).await.unwrap(); + client.connect().await; + + // Send announcement + client.send_event(&announcement).await.unwrap(); + + // Wait for announcement to be processed + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Try to send state event from unauthorized author + let state_event = EventBuilder::new( + Kind::RepoState, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]), + ]) + .sign_with_keys(&unauthorized_keys) + .unwrap(); + + let result = client.send_event(&state_event).await; + + // Should be rejected + match result { + Ok(output) => { + let rejected = output.failed.values().any(|err| { + err.to_string().contains("not authorized") + }); + assert!(rejected, "Event should be rejected due to unauthorized author"); + } + Err(e) => { + assert!( + e.to_string().contains("not authorized") || + e.to_string().contains("rejected"), + "Error should indicate unauthorized author: {}", + e + ); + } + } + + relay.stop().await; +} + +#[tokio::test] +async fn test_accept_state_from_announcement_author() { + // Start test relay + let relay = TestRelay::start().await; + + // Create keypair + let keys = Keys::generate(); + + // Create announcement + let announcement = EventBuilder::new( + Kind::GitRepoAnnouncement, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]), + Tag::custom(TagKind::custom("relays"), [relay.url()]), + ]) + .sign_with_keys(&keys) + .unwrap(); + + // Connect to relay + let client = Client::default(); + client.add_relay(relay.url()).await.unwrap(); + client.connect().await; + + // Send announcement + client.send_event(&announcement).await.unwrap(); + + // Wait for announcement to be processed + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Send state event from same author (should be accepted or go to purgatory) + let state_event = EventBuilder::new( + Kind::RepoState, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let result = client.send_event(&state_event).await; + + // Should be accepted or go to purgatory (not permanently rejected) + match result { + Ok(output) => { + // Check that it wasn't permanently rejected + let permanently_rejected = output.failed.values().any(|err| { + let err_str = err.to_string(); + err_str.contains("not authorized") || err_str.contains("no announcement exists") + }); + assert!( + !permanently_rejected, + "Event should not be permanently rejected when author is authorized" + ); + } + Err(e) => { + // Purgatory is acceptable + assert!( + e.to_string().contains("purgatory") || + e.to_string().contains("waiting for git"), + "Error should be about purgatory, not authorization: {}", + e + ); + } + } + + relay.stop().await; +} + +#[tokio::test] +async fn test_accept_state_from_maintainer() { + // Start test relay + let relay = TestRelay::start().await; + + // Create two keypairs: owner and maintainer + let owner_keys = Keys::generate(); + let maintainer_keys = Keys::generate(); + + // Create announcement with maintainer + let announcement = EventBuilder::new( + Kind::GitRepoAnnouncement, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("clone"), [format!("https://{}/test.git", relay.domain())]), + Tag::custom(TagKind::custom("relays"), [relay.url()]), + Tag::custom(TagKind::custom("maintainers"), [maintainer_keys.public_key().to_hex()]), + ]) + .sign_with_keys(&owner_keys) + .unwrap(); + + // Connect to relay + let client = Client::default(); + client.add_relay(relay.url()).await.unwrap(); + client.connect().await; + + // Send announcement + client.send_event(&announcement).await.unwrap(); + + // Wait for announcement to be processed + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Send state event from maintainer + let state_event = EventBuilder::new( + Kind::RepoState, + "", + ) + .tags([ + Tag::custom(TagKind::custom("d"), ["test-repo"]), + Tag::custom(TagKind::custom("refs/heads/main"), ["abc123"]), + ]) + .sign_with_keys(&maintainer_keys) + .unwrap(); + + let result = client.send_event(&state_event).await; + + // Should be accepted or go to purgatory (not permanently rejected) + match result { + Ok(output) => { + let permanently_rejected = output.failed.values().any(|err| { + let err_str = err.to_string(); + err_str.contains("not authorized") || err_str.contains("no announcement exists") + }); + assert!( + !permanently_rejected, + "Event should not be permanently rejected when maintainer is authorized" + ); + } + Err(e) => { + // Purgatory is acceptable + assert!( + e.to_string().contains("purgatory") || + e.to_string().contains("waiting for git"), + "Error should be about purgatory, not authorization: {}", + e + ); + } + } + + relay.stop().await; +} -- cgit v1.2.3