diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-20 23:44:05 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-11-21 03:05:41 +0000 |
| commit | 2bbb7292c978d36464b6166faa78223677389ef6 (patch) | |
| tree | ef82eb6f9267c27a08e758d32112a33c13991717 /src | |
| parent | 519fdc66930280cd1772417dca327ed858333d64 (diff) | |
Implement GRASP-01 stateful write policy with database queries
- Add Nip34WritePolicy with Arc<MemoryDatabase> for stateful event validation
- Implement full GRASP-01 event acceptance policy:
* Accept events referencing accepted repositories (via a, A, q tags)
* Accept events referencing accepted events (transitive, via e, E, q tags)
* Support forward references (events referenced by accepted events)
* Reject orphan events with no valid references
- Extract and validate all reference tag types (a, A, q, e, E)
- Query database for repository and event existence checks
- Implement fail-secure error handling for database query failures
Test improvements:
- Fix send_and_verify_rejected to handle relay rejection errors properly
- Fix RepoWithIssue fixture usage in forward reference tests
- Add database synchronization polling for race condition mitigation
- Achieve 94% test pass rate (16/17 integration tests passing)
Diffstat (limited to 'src')
| -rw-r--r-- | src/nostr/builder.rs | 270 |
1 files changed, 237 insertions, 33 deletions
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index cd1f4d2..7a35bb7 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -4,8 +4,11 @@ | |||
| 4 | /// preserved from the original implementation. | 4 | /// preserved from the original implementation. |
| 5 | use std::net::SocketAddr; | 5 | use std::net::SocketAddr; |
| 6 | use std::path::Path; | 6 | use std::path::Path; |
| 7 | use std::sync::Arc; | ||
| 7 | 8 | ||
| 8 | use nostr::nips::nip19::ToBech32; | 9 | use nostr::nips::nip19::ToBech32; |
| 10 | use nostr::prelude::{Alphabet, SingleLetterTag}; | ||
| 11 | use nostr::{EventId, Filter, Kind, PublicKey}; | ||
| 9 | use nostr_relay_builder::prelude::*; | 12 | use nostr_relay_builder::prelude::*; |
| 10 | 13 | ||
| 11 | use crate::config::Config; | 14 | use crate::config::Config; |
| @@ -13,20 +16,141 @@ use crate::nostr::events::{ | |||
| 13 | validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, | 16 | validate_announcement, validate_state, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, |
| 14 | }; | 17 | }; |
| 15 | 18 | ||
| 16 | /// NIP-34 Write Policy | 19 | /// NIP-34 Write Policy with Full GRASP-01 Event Validation |
| 17 | /// | 20 | /// |
| 18 | /// Validates repository announcement and state events according to GRASP-01 spec. | 21 | /// Validates all events according to GRASP-01 specification: |
| 19 | /// Preserves all original validation logic from src/nostr/events.rs. | 22 | /// - Repository announcements must list service in clone and relays tags |
| 23 | /// - Repository state announcements must have valid structure | ||
| 24 | /// - Other events must reference accepted repositories or events | ||
| 25 | /// - Forward references are supported (events referenced by accepted events) | ||
| 26 | /// - Orphan events with no valid references are rejected | ||
| 27 | /// | ||
| 28 | /// Uses stateful database queries to check event relationships. | ||
| 20 | #[derive(Debug, Clone)] | 29 | #[derive(Debug, Clone)] |
| 21 | pub struct Nip34WritePolicy { | 30 | pub struct Nip34WritePolicy { |
| 22 | domain: String, | 31 | domain: String, |
| 32 | database: Arc<MemoryDatabase>, | ||
| 23 | } | 33 | } |
| 24 | 34 | ||
| 25 | impl Nip34WritePolicy { | 35 | impl Nip34WritePolicy { |
| 26 | pub fn new(domain: impl Into<String>) -> Self { | 36 | pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>) -> Self { |
| 27 | Self { | 37 | Self { |
| 28 | domain: domain.into(), | 38 | domain: domain.into(), |
| 39 | database, | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | /// Extract all reference tags from an event (a, A, q, e, E) | ||
| 44 | /// Returns (addressable_refs, event_refs) | ||
| 45 | fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) { | ||
| 46 | let mut addressable_refs = Vec::new(); | ||
| 47 | let mut event_refs = Vec::new(); | ||
| 48 | |||
| 49 | for tag in event.tags.iter() { | ||
| 50 | let tag_vec = tag.clone().to_vec(); | ||
| 51 | if tag_vec.is_empty() { | ||
| 52 | continue; | ||
| 53 | } | ||
| 54 | |||
| 55 | match tag_vec[0].as_str() { | ||
| 56 | // Addressable event references (a, A, q with kind:pubkey:identifier format) | ||
| 57 | "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { | ||
| 58 | addressable_refs.push(tag_vec[1].clone()); | ||
| 59 | } | ||
| 60 | // Event ID references (e, E, q with event ID format) | ||
| 61 | "e" | "E" if tag_vec.len() > 1 => { | ||
| 62 | if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { | ||
| 63 | event_refs.push(event_id); | ||
| 64 | } | ||
| 65 | } | ||
| 66 | "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { | ||
| 67 | if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { | ||
| 68 | event_refs.push(event_id); | ||
| 69 | } | ||
| 70 | } | ||
| 71 | _ => {} | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | (addressable_refs, event_refs) | ||
| 76 | } | ||
| 77 | |||
| 78 | /// Check if an addressable event (repository) exists in database | ||
| 79 | async fn is_accepted_repository( | ||
| 80 | database: &Arc<MemoryDatabase>, | ||
| 81 | addressable: &str, | ||
| 82 | ) -> Result<bool, String> { | ||
| 83 | // Parse addressable format: kind:pubkey:identifier | ||
| 84 | let parts: Vec<&str> = addressable.split(':').collect(); | ||
| 85 | if parts.len() < 3 { | ||
| 86 | return Ok(false); | ||
| 87 | } | ||
| 88 | |||
| 89 | let kind = parts[0] | ||
| 90 | .parse::<u16>() | ||
| 91 | .map_err(|e| format!("Invalid kind in addressable: {}", e))?; | ||
| 92 | let pubkey = PublicKey::from_hex(parts[1]) | ||
| 93 | .map_err(|e| format!("Invalid pubkey in addressable: {}", e))?; | ||
| 94 | let identifier = parts[2]; | ||
| 95 | |||
| 96 | // Query database for this addressable event | ||
| 97 | let filter = Filter::new() | ||
| 98 | .kind(Kind::from(kind)) | ||
| 99 | .author(pubkey) | ||
| 100 | .identifier(identifier); | ||
| 101 | |||
| 102 | match database.query(filter).await { | ||
| 103 | Ok(events) => Ok(!events.is_empty()), | ||
| 104 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | /// Check if an event exists in database | ||
| 109 | async fn is_accepted_event( | ||
| 110 | database: &Arc<MemoryDatabase>, | ||
| 111 | event_id: &EventId, | ||
| 112 | ) -> Result<bool, String> { | ||
| 113 | let filter = Filter::new().id(*event_id); | ||
| 114 | |||
| 115 | match database.query(filter).await { | ||
| 116 | Ok(events) => Ok(!events.is_empty()), | ||
| 117 | Err(e) => Err(format!("Database query failed: {}", e)), | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | /// Check if any accepted event references this event ID (forward reference) | ||
| 122 | /// Checks all reference tag types: e, E, q, Q (event ID references only, not addressable) | ||
| 123 | /// | ||
| 124 | /// Note: Must check each tag type separately as custom_tag chaining creates AND not OR | ||
| 125 | async fn is_referenced_by_accepted( | ||
| 126 | database: &Arc<MemoryDatabase>, | ||
| 127 | event_id: &EventId, | ||
| 128 | ) -> Result<bool, String> { | ||
| 129 | let event_id_hex = event_id.to_hex(); | ||
| 130 | |||
| 131 | // Check each tag type that can reference event IDs | ||
| 132 | // Note: 'a' and 'A' tags use addressable format (kind:pubkey:d), not event IDs | ||
| 133 | let tag_types = [ | ||
| 134 | SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference | ||
| 135 | SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference | ||
| 136 | SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference | ||
| 137 | SingleLetterTag::uppercase(Alphabet::Q), // 'Q' - uppercase quote (if used) | ||
| 138 | ]; | ||
| 139 | |||
| 140 | for tag_type in &tag_types { | ||
| 141 | let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone()); | ||
| 142 | |||
| 143 | match database.query(filter).await { | ||
| 144 | Ok(events) => { | ||
| 145 | if !events.is_empty() { | ||
| 146 | return Ok(true); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | Err(e) => return Err(format!("Database query failed: {}", e)), | ||
| 150 | } | ||
| 29 | } | 151 | } |
| 152 | |||
| 153 | Ok(false) | ||
| 30 | } | 154 | } |
| 31 | } | 155 | } |
| 32 | 156 | ||
| @@ -36,86 +160,166 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 36 | event: &'a nostr_relay_builder::prelude::Event, | 160 | event: &'a nostr_relay_builder::prelude::Event, |
| 37 | _addr: &'a SocketAddr, | 161 | _addr: &'a SocketAddr, |
| 38 | ) -> BoxedFuture<'a, PolicyResult> { | 162 | ) -> BoxedFuture<'a, PolicyResult> { |
| 163 | let database = self.database.clone(); | ||
| 164 | let domain = self.domain.clone(); | ||
| 165 | |||
| 39 | Box::pin(async move { | 166 | Box::pin(async move { |
| 167 | let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); | ||
| 168 | |||
| 40 | match event.kind.as_u16() { | 169 | match event.kind.as_u16() { |
| 41 | KIND_REPOSITORY_ANNOUNCEMENT => match validate_announcement(event, &self.domain) { | 170 | KIND_REPOSITORY_ANNOUNCEMENT => match validate_announcement(event, &domain) { |
| 42 | Ok(_) => { | 171 | Ok(_) => { |
| 43 | tracing::debug!( | 172 | tracing::debug!( |
| 44 | "Accepted repository announcement: {}", | 173 | "Accepted repository announcement: {}", |
| 45 | event | 174 | event_id_str |
| 46 | .id | ||
| 47 | .to_bech32() | ||
| 48 | .unwrap_or_else(|_| "invalid".to_string()) | ||
| 49 | ); | 175 | ); |
| 50 | PolicyResult::Accept | 176 | PolicyResult::Accept |
| 51 | } | 177 | } |
| 52 | Err(e) => { | 178 | Err(e) => { |
| 53 | tracing::warn!( | 179 | tracing::warn!( |
| 54 | "Rejected repository announcement {}: {}", | 180 | "Rejected repository announcement {}: {}", |
| 55 | event | 181 | event_id_str, |
| 56 | .id | ||
| 57 | .to_bech32() | ||
| 58 | .unwrap_or_else(|_| "invalid".to_string()), | ||
| 59 | e | 182 | e |
| 60 | ); | 183 | ); |
| 61 | PolicyResult::Reject(e.to_string()) | 184 | PolicyResult::Reject(e.to_string()) |
| 62 | } | 185 | } |
| 63 | }, | 186 | }, |
| 64 | KIND_REPOSITORY_STATE => match validate_state(event) { | 187 | KIND_REPOSITORY_STATE =>match validate_state(event) { |
| 65 | Ok(_) => { | 188 | Ok(_) => { |
| 66 | tracing::debug!( | 189 | tracing::debug!( |
| 67 | "Accepted repository state: {}", | 190 | "Accepted repository state: {}", |
| 68 | event | 191 | event_id_str |
| 69 | .id | ||
| 70 | .to_bech32() | ||
| 71 | .unwrap_or_else(|_| "invalid".to_string()) | ||
| 72 | ); | 192 | ); |
| 73 | PolicyResult::Accept | 193 | PolicyResult::Accept |
| 74 | } | 194 | } |
| 75 | Err(e) => { | 195 | Err(e) => { |
| 76 | tracing::warn!( | 196 | tracing::warn!( |
| 77 | "Rejected repository state {}: {}", | 197 | "Rejected repository state {}: {}", |
| 78 | event | 198 | event_id_str, |
| 79 | .id | ||
| 80 | .to_bech32() | ||
| 81 | .unwrap_or_else(|_| "invalid".to_string()), | ||
| 82 | e | 199 | e |
| 83 | ); | 200 | ); |
| 84 | PolicyResult::Reject(e.to_string()) | 201 | PolicyResult::Reject(e.to_string()) |
| 85 | } | 202 | } |
| 86 | }, | 203 | }, |
| 87 | // Accept all other event kinds without validation | 204 | // GRASP-01: Check if event references accepted repositories or events |
| 88 | _ => PolicyResult::Accept, | 205 | _ => { |
| 206 | // Extract all reference tags from event | ||
| 207 | let (addressable_refs, event_refs) = Self::extract_reference_tags(event); | ||
| 208 | |||
| 209 | // Check 1: Does this event reference an accepted repository? | ||
| 210 | for addr_ref in &addressable_refs { | ||
| 211 | match Self::is_accepted_repository(&database, addr_ref).await { | ||
| 212 | Ok(true) => { | ||
| 213 | tracing::debug!( | ||
| 214 | "Accepted event {}: references accepted repository {}", | ||
| 215 | event_id_str, | ||
| 216 | addr_ref | ||
| 217 | ); | ||
| 218 | return PolicyResult::Accept; | ||
| 219 | } | ||
| 220 | Ok(false) => { | ||
| 221 | // Continue checking other references | ||
| 222 | } | ||
| 223 | Err(e) => { | ||
| 224 | tracing::warn!( | ||
| 225 | "Database query failed for event {}, rejecting (fail-secure): {}", | ||
| 226 | event_id_str, | ||
| 227 | e | ||
| 228 | ); | ||
| 229 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 230 | } | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | // Check 2: Does this event reference an accepted event? (transitive) | ||
| 235 | for event_ref in &event_refs { | ||
| 236 | match Self::is_accepted_event(&database, event_ref).await { | ||
| 237 | Ok(true) => { | ||
| 238 | tracing::debug!( | ||
| 239 | "Accepted event {}: references accepted event {}", | ||
| 240 | event_id_str, | ||
| 241 | event_ref | ||
| 242 | ); | ||
| 243 | return PolicyResult::Accept; | ||
| 244 | } | ||
| 245 | Ok(false) => { | ||
| 246 | // Continue checking other references | ||
| 247 | } | ||
| 248 | Err(e) => { | ||
| 249 | tracing::warn!( | ||
| 250 | "Database query failed for event {}, rejecting (fail-secure): {}", | ||
| 251 | event_id_str, | ||
| 252 | e | ||
| 253 | ); | ||
| 254 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 255 | } | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | // Check 3: Is this event referenced by an accepted event? (forward reference) | ||
| 260 | match Self::is_referenced_by_accepted(&database, &event.id).await { | ||
| 261 | Ok(true) => { | ||
| 262 | tracing::debug!( | ||
| 263 | "Accepted event {}: referenced by accepted event", | ||
| 264 | event_id_str | ||
| 265 | ); | ||
| 266 | return PolicyResult::Accept; | ||
| 267 | } | ||
| 268 | Ok(false) => { | ||
| 269 | // No forward references found, continue to rejection | ||
| 270 | } | ||
| 271 | Err(e) => { | ||
| 272 | tracing::warn!( | ||
| 273 | "Database query failed for event {}, rejecting (fail-secure): {}", | ||
| 274 | event_id_str, | ||
| 275 | e | ||
| 276 | ); | ||
| 277 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | // No valid references found - reject as orphan event | ||
| 282 | tracing::info!( | ||
| 283 | "Rejected orphan event {}: no references to accepted repos or events (checked {} addressable, {} event refs)", | ||
| 284 | event_id_str, | ||
| 285 | addressable_refs.len(), | ||
| 286 | event_refs.len() | ||
| 287 | ); | ||
| 288 | PolicyResult::Reject( | ||
| 289 | "Event must reference an accepted repository or accepted event".to_string() | ||
| 290 | ) | ||
| 291 | } | ||
| 89 | } | 292 | } |
| 90 | }) | 293 | }) |
| 91 | } | 294 | } |
| 92 | } | 295 | } |
| 93 | 296 | ||
| 94 | /// Create a configured LocalRelay with NIP-34 validation | 297 | /// Create a configured LocalRelay with full GRASP-01 validation |
| 95 | pub fn create_relay(config: &Config) -> Result<LocalRelay> { | 298 | pub fn create_relay(config: &Config) -> Result<LocalRelay> { |
| 96 | tracing::info!("Configuring nostr relay..."); | 299 | tracing::info!("Configuring nostr relay with GRASP-01 validation..."); |
| 97 | 300 | ||
| 98 | // Determine database path | 301 | // Determine database path |
| 99 | let db_path = Path::new(&config.relay_data_path); | 302 | let db_path = Path::new(&config.relay_data_path); |
| 100 | 303 | ||
| 101 | // Create database - using in-memory for now, can switch to persistent later | 304 | // Create database - using in-memory for now, can switch to persistent later |
| 102 | // TODO: Add configuration for NostrDB or LMDB backends | 305 | // TODO: Add configuration for NostrDB or LMDB backends |
| 103 | let database = MemoryDatabase::with_opts(MemoryDatabaseOptions { | 306 | let database = Arc::new(MemoryDatabase::with_opts(MemoryDatabaseOptions { |
| 104 | events: true, | 307 | events: true, |
| 105 | max_events: Some(100_000), | 308 | max_events: Some(100_000), |
| 106 | }); | 309 | })); |
| 107 | 310 | ||
| 108 | tracing::info!("Using in-memory database (path: {})", db_path.display()); | 311 | tracing::info!("Using in-memory database (path: {})", db_path.display()); |
| 109 | 312 | ||
| 110 | // Build relay with NIP-34 validation | 313 | // Build relay with GRASP-01 validation |
| 314 | // Clone Arc for the write policy so both relay and policy can access the database | ||
| 111 | let builder = RelayBuilder::default() | 315 | let builder = RelayBuilder::default() |
| 112 | .database(database) | 316 | .database(database.clone()) |
| 113 | .write_policy(Nip34WritePolicy::new(&config.domain)); | 317 | .write_policy(Nip34WritePolicy::new(&config.domain, database.clone())); |
| 114 | 318 | ||
| 115 | tracing::info!( | 319 | tracing::info!( |
| 116 | "Relay configured with NIP-34 validation for domain: {}", | 320 | "Relay configured with GRASP-01 validation for domain: {}", |
| 117 | config.domain | 321 | config.domain |
| 118 | ); | 322 | ); |
| 119 | 323 | ||
| 120 | Ok(LocalRelay::new(builder)) | 324 | Ok(LocalRelay::new(builder)) |
| 121 | } | 325 | } \ No newline at end of file |