From c8ab2c9c294ae9401ff542d0eecc6606b7908412 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 21:51:57 +0000 Subject: feat(config): add event blacklist to block all events from specific authors Adds NGIT_EVENT_BLACKLIST option for blocking all events from specific npubs, taking precedence over all other validation to enable comprehensive moderation without affecting curation policy. Key features: - Simple npub-only format: ,,... - Checked FIRST before any other validation (including repository blacklist) - Blocks ALL event types (announcements, state events, PRs, comments, etc.) - Events never reach relay storage or purgatory - Specific rejection reason for operator debugging Implementation: - Add EventBlacklistConfig struct with check() method - Add NGIT_EVENT_BLACKLIST config option and event_blacklist_config() method - Add config field to PolicyContext for policy access - Add check_event_blacklist() to Nip34WritePolicy - Check event blacklist first in admit_event() method (before any other validation) - 4 new unit tests covering all blacklist behavior Configuration synced across all four sources: - src/config.rs: Core implementation with EventBlacklistConfig - .env.example: Comprehensive documentation with examples - docs/reference/configuration.md: Complete reference documentation - nix/module.nix: NixOS module option with environment mapping README updates: - Add comprehensive "Curation & Moderation" section - Document repository whitelists (GRASP-01 and GRASP-05 modes) - Document repository and event blacklists with precedence order - Add configuration table for all curation/moderation settings - Provide real-world examples for different relay configurations Testing: - 4 new tests for event blacklist functionality - All 336 library tests passing - All 64 integration tests passing - All 38 filter support tests passing Verification: - Repository blacklist confirmed to apply to sync (uses same admit_event flow) - Sync events validated through process_event_static -> write_policy.admit_event Use cases: - Block spam/abusive users completely - Prevent malicious actors from submitting any events - Temporary blocks for investigation - Moderation without affecting whitelist curation policy --- .env.example | 25 ++++++++- README.md | 100 +++++++++++++++++++++++++++++++++++- docs/reference/configuration.md | 92 +++++++++++++++++++++++++++++++++ nix/module.nix | 14 +++++ src/config.rs | 110 ++++++++++++++++++++++++++++++++++++++++ src/nostr/builder.rs | 32 +++++++++++- src/nostr/policy/mod.rs | 4 ++ 7 files changed, 374 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 993399a..fb954c0 100644 --- a/.env.example +++ b/.env.example @@ -254,4 +254,27 @@ # NGIT_REPOSITORY_BLACKLIST=npub1spam... # NGIT_REPOSITORY_BLACKLIST=npub1alice.../bad-repo # NGIT_REPOSITORY_BLACKLIST=malware-repo,spam-repo -# NGIT_REPOSITORY_BLACKLIST= \ No newline at end of file +# NGIT_REPOSITORY_BLACKLIST= + +# ============================================================================ +# EVENT BLACKLIST +# ============================================================================ + +# Blacklist events from specific authors (npubs) +# Comma-separated list of npubs whose events are rejected +# ALL events from these authors are blocked from both relay storage and purgatory +# +# Event blacklist takes precedence over ALL other validation: +# - Blacklisted events are rejected before any other policy checks +# - Applies to announcements, state events, PRs, and all other event types +# - Events never reach purgatory (rejected immediately) +# +# Rejection reason: +# - "Event author is blacklisted" +# +# CLI: --event-blacklist +# Default: (empty - no events are blacklisted by author) +# Examples: +# NGIT_EVENT_BLACKLIST=npub1spam... +# NGIT_EVENT_BLACKLIST=npub1spam...,npub1abuser... +# NGIT_EVENT_BLACKLIST= \ No newline at end of file diff --git a/README.md b/README.md index 50bee24..b4a430f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15 - **Pure Rust Implementation**: Single binary, no external dependencies beyond Git itself - **Integrated Authorization**: Push validation happens inline during the Git receive-pack operation - **GRASP-01 Compliant**: Core service requirements for Git hosting with Nostr authorization - - **Repository Whitelist/Blacklist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) and blacklist (overrides all whitelists) + - **Flexible Curation & Moderation**: Repository whitelists (GRASP-01 mode), repository blacklists (moderation), and event blacklists (author blocking) - **GRASP-02 Proactive Sync**: Sophisticated relay-to-relay event and git data synchronization - **NIP-77 Negentropy**: Efficient set reconciliation with automatic fallback to REQ+EOSE - **Live & Historic Sync**: Real-time event streaming plus catch-up for past events @@ -150,6 +150,93 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f **See**: [GRASP-05 Archive Mode](docs/explanation/grasp-05-archive.md) +## Curation & Moderation + +ngit-grasp provides flexible tools for both curation (repository selection) and moderation (blocking spam/abuse): + +### Repository Whitelists (Curation) + +Control which repositories your relay accepts via two independent whitelist modes: + +**Repository Whitelist (GRASP-01 Mode):** +- Only accept announcements that **both** list your service AND match the whitelist +- Three formats: ``, `/`, `` +- Environment: `NGIT_REPOSITORY_WHITELIST=npub1alice...,bitcoin-core` +- Use case: Curated relay accepting specific projects/developers + +**Archive Whitelist (GRASP-05 Mode):** +- Accept announcements matching the whitelist **even if they don't list your service** +- Same three formats as repository whitelist +- Environment: `NGIT_ARCHIVE_WHITELIST=npub1satoshi...,linux` +- Use case: Backup/mirror relay for critical projects +- Default: Read-only mode (`NGIT_ARCHIVE_READ_ONLY=true`) + +Both whitelists support flexible matching: +```bash +# Accept all repos from specific developer +NGIT_REPOSITORY_WHITELIST=npub1alice... + +# Accept specific repository +NGIT_REPOSITORY_WHITELIST=npub1alice.../my-project + +# Accept repos with specific identifier (any author) +NGIT_REPOSITORY_WHITELIST=bitcoin-core +``` + +### Blacklists (Moderation) + +Block unwanted content without affecting your curation policy: + +**Repository Blacklist:** +- Block specific repositories/developers/identifiers +- **Takes precedence over ALL whitelists** (checked first) +- Three formats: ``, `/`, `` +- Environment: `NGIT_REPOSITORY_BLACKLIST=npub1spam...,malware-repo` +- Use case: Block spam/malware repos while maintaining whitelist curation + +**Event Blacklist:** +- Block **ALL events** from specific authors (npubs) +- **Takes precedence over ALL other validation** (checked first) +- Applies to all event types: announcements, state events, PRs, comments, etc. +- Events never reach relay storage or purgatory +- Environment: `NGIT_EVENT_BLACKLIST=npub1spammer...,npub1abuser...` +- Use case: Block abusive users completely + +### Precedence & Interaction + +Validation order (from first to last): + +1. **Event Blacklist** → Reject if author is blacklisted (ALL event types) +2. **Repository Blacklist** → Reject if repository/npub/identifier is blacklisted (announcements only) +3. **Repository Whitelist** → Accept if announcement lists service AND matches whitelist +4. **Archive Whitelist** → Accept if announcement matches whitelist (even without listing service) +5. **Default GRASP-01** → Accept if announcement lists service (no whitelist configured) + +Examples: +```bash +# Curated relay blocking spam +NGIT_REPOSITORY_WHITELIST=npub1alice...,npub1bob... +NGIT_REPOSITORY_BLACKLIST=npub1alice.../spam-repo +NGIT_EVENT_BLACKLIST=npub1spammer... +# Result: Accept Alice & Bob's repos EXCEPT Alice's spam-repo, block all events from spammer + +# Archive relay with moderation +NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux +NGIT_EVENT_BLACKLIST=npub1abuser... +# Result: Mirror bitcoin-core and linux projects, block all events from abuser + +# Public relay with spam protection +NGIT_EVENT_BLACKLIST=npub1spam1...,npub1spam2... +# Result: Accept all GRASP-01 repos, block all events from spammers +``` + +**Privacy & Transparency:** +- Blacklists are **not advertised** in NIP-11 metadata (operational, not curation policy) +- Rejected events receive specific error messages for operator debugging +- No client-visible indication that blacklists are in use + +**See**: [Configuration Reference](docs/reference/configuration.md) for complete details + ## Roadmap ### GRASP-02 Enhancements @@ -326,6 +413,17 @@ NGIT_RELAY_OWNER_NSEC=nsec1... ngit-grasp --domain relay.example.com | Disable negentropy | `--sync-disable-negentropy` | `NGIT_SYNC_DISABLE_NEGENTROPY` | `false` | | Batch window | N/A | `NGIT_SYNC_BATCH_WINDOW_MS` | `5000` ms | +#### Curation & Moderation Settings + +| Option | CLI Flag | Environment Variable | Default | +| -------------------- | --------------------------- | ------------------------------ | --------- | +| Repository whitelist | `--repository-whitelist` | `NGIT_REPOSITORY_WHITELIST` | (empty) | +| Archive whitelist | `--archive-whitelist` | `NGIT_ARCHIVE_WHITELIST` | (empty) | +| Archive all | `--archive-all` | `NGIT_ARCHIVE_ALL` | `false` | +| Archive read-only | `--archive-read-only` | `NGIT_ARCHIVE_READ_ONLY` | (auto) | +| Repository blacklist | `--repository-blacklist` | `NGIT_REPOSITORY_BLACKLIST` | (empty) | +| Event blacklist | `--event-blacklist` | `NGIT_EVENT_BLACKLIST` | (empty) | + **Sync Notes:** - **Bootstrap relay**: Optional starting point for relay discovery. System automatically discovers additional relays from repository announcements. URL scheme is optional - if not provided, `wss://` is assumed (e.g., `git.shakespeare.diy` → `wss://git.shakespeare.diy`). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b90686e..66f39f1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -833,6 +833,98 @@ Blacklist does **not** affect NIP-11 metadata: --- +### Event Blacklist + +#### `NGIT_EVENT_BLACKLIST` + +**Description:** Blacklist events from specific authors (npubs) +**Type:** Comma-separated list of npubs +**Default:** Empty (no events are blacklisted by author) +**Required:** No + +**Format:** +- `npub1...` - Block all events from this author + +**Precedence:** Event blacklist takes precedence over **ALL** other validation: +- Blacklisted events are rejected **before** any other policy checks +- Applies to all event types (announcements, state events, PRs, etc.) +- Events never reach purgatory (rejected immediately) +- Overrides repository blacklist, whitelists, and all other policies + +**Examples:** + +```bash +# Block all events from specific author +NGIT_EVENT_BLACKLIST=npub1spam... + +# Block events from multiple authors +NGIT_EVENT_BLACKLIST=npub1spam...,npub1abuser...,npub1troll... +``` + +**Rejection Reason:** + +The event blacklist provides a specific rejection reason: +- **Format:** `"Event author is blacklisted"` + +This reason helps operators understand why an event was rejected without needing to flag it in metadata. + +**Behavior:** + +Event blacklist is checked **first** before all other validation: +1. Check event blacklist → Reject if author is blacklisted +2. Check repository blacklist (for announcements) → Reject if matched +3. Check event-type specific policies → Accept/Reject based on policy +4. Process event normally + +**Use Cases:** + +```bash +# Block spam/abusive users +NGIT_EVENT_BLACKLIST=npub1spammer...,npub1abuser... + +# Block malicious actors +NGIT_EVENT_BLACKLIST=npub1malware...,npub1phisher... + +# Temporary block for investigation +NGIT_EVENT_BLACKLIST=npub1suspicious... +``` + +**Comparison with Repository Blacklist:** + +| Configuration | Scope | Checked When | Applies To | +|---------------|-------|--------------|------------| +| Event Blacklist | Author-based | **First** (before all policies) | **All events** from author | +| Repository Blacklist | Repo-based | Second (announcements only) | Specific repositories | + +**Event Blacklist vs Repository Blacklist:** + +```bash +# Scenario: npub1alice is event-blacklisted +NGIT_EVENT_BLACKLIST=npub1alice... + +# Result: +# - ALL events from npub1alice are rejected (announcements, PRs, etc.) +# - Events never reach relay or purgatory +# - Rejection: "Event author npub1alice... is blacklisted" + +# Scenario: npub1alice/repo is repository-blacklisted +NGIT_REPOSITORY_BLACKLIST=npub1alice.../malware + +# Result: +# - Only announcements for npub1alice.../malware are rejected +# - Other events from npub1alice are still processed normally +# - PRs/state events for different repos from npub1alice are accepted +``` + +**NIP-11 Impact:** + +Event blacklist does **not** affect NIP-11 metadata: +- No `curation` field changes (blacklist is operational, not policy) +- Blacklist is transparent to clients (rejected with specific reason) +- Operators can use blacklist without advertising moderation + +--- + ### Logging Configuration #### `RUST_LOG` diff --git a/nix/module.nix b/nix/module.nix index cfac0fc..799ae2d 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -237,6 +237,19 @@ let ''; }; + eventBlacklist = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "npub1spam..." "npub1abuser..." ]; + description = '' + Event blacklist for blocking all events from specific authors (npubs). + Takes precedence over ALL other validation (checked first). + ALL events from these authors are rejected from relay storage and purgatory. + Applies to announcements, state events, PRs, and all other event types. + Does not affect NIP-11 metadata (operational, not curation policy). + ''; + }; + user = mkOption { type = types.str; default = "ngit-grasp-${name}"; @@ -281,6 +294,7 @@ let NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; + NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist; RUST_LOG = cfg.logLevel; } // optionalAttrs (cfg.relayName != null) { NGIT_RELAY_NAME = cfg.relayName; diff --git a/src/config.rs b/src/config.rs index 5f8cbca..a5e4344 100644 --- a/src/config.rs +++ b/src/config.rs @@ -244,6 +244,42 @@ impl Default for BlacklistConfig { } } +/// Event blacklist configuration for blocking events by author npub +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventBlacklistConfig { + /// Blacklisted npubs - events from these authors are rejected + /// + /// If empty, no events are blacklisted by author. + /// Applies to ALL event types, preventing events from reaching both the relay and purgatory. + pub blacklisted_npubs: Vec, +} + +impl EventBlacklistConfig { + /// Check if event blacklist is enabled (non-empty blacklist) + pub fn enabled(&self) -> bool { + !self.blacklisted_npubs.is_empty() + } + + /// Check if an event author is blacklisted + /// + /// Returns Some(reason) if blacklisted, None if not blacklisted. + pub fn check(&self, npub: &str) -> Option { + if self.blacklisted_npubs.contains(&npub.to_string()) { + Some(format!("Event author {} is blacklisted", npub)) + } else { + None + } + } +} + +impl Default for EventBlacklistConfig { + fn default() -> Self { + Self { + blacklisted_npubs: Vec::new(), + } + } +} + /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -428,6 +464,11 @@ pub struct Config { /// Blacklist takes precedence over all whitelists (archive and repository) #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] pub repository_blacklist: String, + + /// Event blacklist: comma-separated list of npubs whose events are rejected + /// All events from these authors are blocked from both relay storage and purgatory + #[arg(long, env = "NGIT_EVENT_BLACKLIST", default_value = "")] + pub event_blacklist: String, } impl Config { @@ -612,6 +653,20 @@ impl Config { BlacklistConfig { blacklist } } + /// Get parsed event blacklist configuration + /// + /// This method assumes config has been validated - call Config::validate() first! + pub fn event_blacklist_config(&self) -> EventBlacklistConfig { + let blacklisted_npubs: Vec = self + .event_blacklist + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + EventBlacklistConfig { blacklisted_npubs } + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -647,6 +702,7 @@ impl Config { archive_read_only: None, repository_whitelist: String::new(), repository_blacklist: String::new(), + event_blacklist: String::new(), } } } @@ -1248,4 +1304,58 @@ mod tests { let result = config.check(&test_npub, "allowed-repo"); assert!(result.is_none()); } + + #[test] + fn test_event_blacklist_config_parsing() { + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + let npub1 = keys1.public_key().to_bech32().unwrap(); + let npub2 = keys2.public_key().to_bech32().unwrap(); + let config = Config { + event_blacklist: format!("{},{}", npub1, npub2), + ..Config::for_testing() + }; + let event_blacklist_config = config.event_blacklist_config(); + assert_eq!(event_blacklist_config.blacklisted_npubs.len(), 2); + assert!(event_blacklist_config.enabled()); + assert!(event_blacklist_config.blacklisted_npubs.contains(&npub1)); + assert!(event_blacklist_config.blacklisted_npubs.contains(&npub2)); + } + + #[test] + fn test_event_blacklist_config_empty() { + let config = Config::for_testing(); + let event_blacklist_config = config.event_blacklist_config(); + assert!(event_blacklist_config.blacklisted_npubs.is_empty()); + assert!(!event_blacklist_config.enabled()); + } + + #[test] + fn test_event_blacklist_check_blacklisted() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = EventBlacklistConfig { + blacklisted_npubs: vec![test_npub.clone()], + }; + + let result = config.check(&test_npub); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains("author")); + assert!(reason.contains(&test_npub)); + } + + #[test] + fn test_event_blacklist_check_not_blacklisted() { + let keys1 = Keys::generate(); + let keys2 = Keys::generate(); + let banned_npub = keys1.public_key().to_bech32().unwrap(); + let allowed_npub = keys2.public_key().to_bech32().unwrap(); + let config = EventBlacklistConfig { + blacklisted_npubs: vec![banned_npub], + }; + + let result = config.check(&allowed_npub); + assert!(result.is_none()); + } } diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 9819e37..c2de1df 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -56,7 +56,13 @@ impl Nip34WritePolicy { purgatory: std::sync::Arc, config: crate::config::Config, ) -> Self { - let ctx = PolicyContext::new(&config.domain, database, git_data_path, purgatory); + let ctx = PolicyContext::new( + &config.domain, + database, + git_data_path, + purgatory, + config.clone(), + ); Self { announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()), state_policy: StatePolicy::new(ctx.clone()), @@ -66,6 +72,19 @@ impl Nip34WritePolicy { } } + /// Check if an event author is blacklisted + /// + /// Returns Some(reason) if blacklisted, None if not blacklisted. + fn check_event_blacklist(&self, event: &Event) -> Option { + let event_blacklist = self.ctx.config.event_blacklist_config(); + if !event_blacklist.enabled() { + return None; + } + + let npub = event.pubkey.to_bech32().ok()?; + event_blacklist.check(&npub) + } + /// Get a reference to the purgatory for read-only access pub fn purgatory(&self) -> &std::sync::Arc { &self.ctx.purgatory @@ -474,6 +493,17 @@ impl WritePolicy for Nip34WritePolicy { addr: &'a SocketAddr, ) -> BoxedFuture<'a, WritePolicyResult> { Box::pin(async move { + // Check event blacklist FIRST - it overrides everything + if let Some(reason) = self.check_event_blacklist(event) { + tracing::debug!( + event_id = %event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()), + author = %event.pubkey.to_hex(), + reason = %reason, + "Rejected event from blacklisted author" + ); + return WritePolicyResult::reject(reason); + } + // Detect if this is a synced event (from proactive sync) vs user-submitted // Sync uses localhost:0 as a dummy address let is_synced = addr.ip().is_loopback() && addr.port() == 0; diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index dc023a9..1566b6c 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs @@ -32,6 +32,8 @@ pub struct PolicyContext { pub purgatory: Arc, /// Local relay for notifying WebSocket subscribers (set after relay creation) pub local_relay: Arc>>, + /// Configuration reference for policy settings (includes blacklists) + pub config: crate::config::Config, } impl PolicyContext { @@ -40,6 +42,7 @@ impl PolicyContext { database: SharedDatabase, git_data_path: impl Into, purgatory: Arc, + config: crate::config::Config, ) -> Self { Self { domain: domain.into(), @@ -47,6 +50,7 @@ impl PolicyContext { git_data_path: git_data_path.into(), purgatory, local_relay: Arc::new(std::sync::RwLock::new(None)), + config, } } -- cgit v1.2.3