From 70c577f10bbe150b6b13bec545dc8720ad005a64 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 12 Jan 2026 21:32:38 +0000 Subject: feat(config): add repository blacklist to block specific repos/npubs/identifiers Adds NGIT_REPOSITORY_BLACKLIST option for blocking repositories, taking precedence over all whitelists (archive and repository) to enable moderation without affecting curation policy. Key features: - Three blacklist formats: , /, - Blacklist checked first before any other validation - Overrides archive whitelist and repository whitelist - Specific rejection reasons based on match type (npub/identifier/both) - Not flagged in NIP-11 curation (operational, not policy) Implementation: - Add BlacklistConfig struct with check() method returning detailed reasons - Add NGIT_REPOSITORY_BLACKLIST config option and blacklist_config() method - Update validate_announcement() to check blacklist first with specific reasons - 12 new unit tests covering all blacklist behavior and precedence Configuration synced across all four sources: - src/config.rs: Core implementation with BlacklistConfig - .env.example: Comprehensive documentation with examples - docs/reference/configuration.md: Complete reference documentation - nix/module.nix: NixOS module option with environment mapping Testing: - 12 new tests for blacklist functionality (config + validation) - All 332 library tests passing - All 38 integration tests passing Use cases: - Block spam/malware repos by identifier - Block abusive users by npub - Block specific problematic repos by npub/identifier - Temporary blocks for investigation --- .env.example | 29 ++++++- README.md | 2 +- docs/reference/configuration.md | 89 +++++++++++++++++++ nix/module.nix | 14 +++ src/config.rs | 143 ++++++++++++++++++++++++++++++ src/nostr/events.rs | 187 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 462 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 0789b28..993399a 100644 --- a/.env.example +++ b/.env.example @@ -227,4 +227,31 @@ # NGIT_REPOSITORY_WHITELIST=bitcoin-core,linux,rust # Note: Cannot be used with NGIT_ARCHIVE_READ_ONLY=true (mutually exclusive) # Note: When set, NIP-11 curation field will indicate curated repository acceptance -# NGIT_REPOSITORY_WHITELIST= \ No newline at end of file +# NGIT_REPOSITORY_WHITELIST= + +# ============================================================================ +# REPOSITORY BLACKLIST +# ============================================================================ + +# Blacklist specific repos/pubkeys/identifiers to reject +# Comma-separated list supporting three formats (same as whitelist formats): +# - Block all repos from this pubkey +# / - Block specific repo +# - Block repos with this identifier (any pubkey) +# +# Blacklist takes precedence over ALL whitelists: +# - Blacklisted repos are rejected even if they match archive or repository whitelists +# - Blacklisted repos are rejected even if they list our service +# +# Rejection reasons indicate the match type: +# - "Repository owner is blacklisted" (npub format) +# - "Repository / is blacklisted" (npub/identifier format) +# - "Repository identifier is blacklisted" (identifier format) +# +# CLI: --repository-blacklist +# Default: (empty - no repositories are blacklisted) +# Examples: +# 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 diff --git a/README.md b/README.md index c9d1066..50bee24 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**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) + - **Repository Whitelist/Blacklist**: Optional curation via pubkey/identifier whitelist (GRASP-01 mode) and blacklist (overrides all whitelists) - **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 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1c62911..b90686e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -744,6 +744,95 @@ NGIT_REPOSITORY_WHITELIST=bitcoin-core,npub1alice... --- +### Repository Blacklist + +#### `NGIT_REPOSITORY_BLACKLIST` + +**Description:** Blacklist specific repositories/pubkeys/identifiers to reject +**Type:** Comma-separated list +**Default:** Empty (no repositories are blacklisted) +**Required:** No + +**Format:** Same as whitelist formats: +- `npub1...` - Block all repos from this pubkey +- `npub1.../identifier` - Block specific repo +- `identifier` - Block repos with this identifier (any pubkey) + +**Precedence:** Blacklist takes precedence over **ALL** whitelists: +- Blacklisted repos are rejected even if they match archive or repository whitelists +- Blacklisted repos are rejected even if they list our service +- Blacklist is checked **first** before any other validation + +**Examples:** + +```bash +# Block all repos from specific pubkey +NGIT_REPOSITORY_BLACKLIST=npub1spam... + +# Block specific repo +NGIT_REPOSITORY_BLACKLIST=npub1alice.../malware-repo + +# Block repos with specific identifiers +NGIT_REPOSITORY_BLACKLIST=malware,spam,phishing + +# Combined blacklist +NGIT_REPOSITORY_BLACKLIST=npub1spam...,npub1alice.../bad-repo,malware +``` + +**Rejection Reasons:** + +The blacklist provides specific rejection reasons based on the match type: + +- **Npub format:** `"Repository owner is blacklisted"` +- **Npub/identifier format:** `"Repository / is blacklisted"` +- **Identifier format:** `"Repository identifier is blacklisted"` + +These reasons help operators understand why a repository was rejected without needing to flag it in curation metadata. + +**Behavior:** + +Blacklist is checked **before** all other validation: +1. Check blacklist → Reject if matched +2. Check if lists service → Accept if matches repository whitelist (if enabled) +3. Check archive config → Accept if matches archive whitelist (if enabled) +4. Reject otherwise + +**Use Cases:** + +```bash +# Block spam/malware repos +NGIT_REPOSITORY_BLACKLIST=malware,spam,phishing + +# Block abusive users +NGIT_REPOSITORY_BLACKLIST=npub1spammer...,npub1abuser... + +# Block specific problematic repos +NGIT_REPOSITORY_BLACKLIST=npub1alice.../copyright-violation,npub1bob.../illegal-content + +# Temporary block for investigation +NGIT_REPOSITORY_BLACKLIST=npub1suspicious.../repo-under-review +``` + +**Comparison with Whitelists:** + +| Configuration | Blacklisted? | Matches Whitelist? | Lists Service? | Result | +|---------------|--------------|-------------------|----------------|---------| +| Blacklist only | Yes | N/A | N/A | ❌ Reject (blacklisted) | +| Blacklist only | No | N/A | Yes | ✅ Accept (GRASP-01) | +| Blacklist + Repository whitelist | Yes | Yes | Yes | ❌ Reject (blacklist wins) | +| Blacklist + Archive whitelist | Yes | Yes | No | ❌ Reject (blacklist wins) | +| Blacklist + Both whitelists | Yes | Yes | Yes | ❌ Reject (blacklist wins) | +| Blacklist only | No | N/A | No | ❌ Reject (no whitelist match) | + +**NIP-11 Impact:** + +Blacklist does **not** affect NIP-11 metadata: +- No `curation` field changes (blacklist is operational, not curation policy) +- Blacklist is transparent to clients (rejected with specific reason) +- Operators can use blacklist without advertising curation + +--- + ### Logging Configuration #### `RUST_LOG` diff --git a/nix/module.nix b/nix/module.nix index d5dfd88..cfac0fc 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -224,6 +224,19 @@ let ''; }; + repositoryBlacklist = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "npub1spam..." "npub1alice.../bad-repo" "malware" ]; + description = '' + Repository blacklist for blocking specific repositories/pubkeys/identifiers. + Blacklist takes precedence over ALL whitelists (archive and repository). + Formats: , /, + Blacklisted repos are rejected with specific reasons (npub/identifier/both). + Does not affect NIP-11 curation field (operational, not curation policy). + ''; + }; + user = mkOption { type = types.str; default = "ngit-grasp-${name}"; @@ -267,6 +280,7 @@ let NGIT_ARCHIVE_ALL = toString cfg.archiveAll; NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; + NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; RUST_LOG = cfg.logLevel; } // optionalAttrs (cfg.relayName != null) { NGIT_RELAY_NAME = cfg.relayName; diff --git a/src/config.rs b/src/config.rs index 37b1c1e..5f8cbca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -195,6 +195,55 @@ impl Default for RepositoryConfig { } } +/// Repository blacklist configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlacklistConfig { + /// Blacklist entries for blocking specific repositories + /// + /// If empty, no repositories are blacklisted. + /// Blacklist takes precedence over both archive and repository whitelists. + pub blacklist: Vec, +} + +impl BlacklistConfig { + /// Check if repository blacklist is enabled (non-empty blacklist) + pub fn enabled(&self) -> bool { + !self.blacklist.is_empty() + } + + /// Check if an announcement matches the repository blacklist + /// + /// Returns Some(reason) if blacklisted, None if not blacklisted. + /// The reason indicates what type of match occurred (npub, npub/identifier, or identifier). + pub fn check(&self, npub: &str, identifier: &str) -> Option { + for entry in &self.blacklist { + if entry.matches(npub, identifier) { + let reason = match entry { + WhitelistEntry::Pubkey(_) => { + format!("Repository owner {} is blacklisted", npub) + } + WhitelistEntry::Repository { .. } => { + format!("Repository {}/{} is blacklisted", npub, identifier) + } + WhitelistEntry::Identifier(_) => { + format!("Repository identifier {} is blacklisted", identifier) + } + }; + return Some(reason); + } + } + None + } +} + +impl Default for BlacklistConfig { + fn default() -> Self { + Self { + blacklist: Vec::new(), + } + } +} + /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -373,6 +422,12 @@ pub struct Config { /// When set, only announcements matching the whitelist AND listing the service are accepted #[arg(long, env = "NGIT_REPOSITORY_WHITELIST", default_value = "")] pub repository_whitelist: String, + + /// Repository blacklist: comma-separated list of npub/identifier/npub/identifier entries to reject + /// Formats: "npub1...", "npub1.../identifier", "identifier" + /// Blacklist takes precedence over all whitelists (archive and repository) + #[arg(long, env = "NGIT_REPOSITORY_BLACKLIST", default_value = "")] + pub repository_blacklist: String, } impl Config { @@ -549,6 +604,14 @@ impl Config { RepositoryConfig { whitelist } } + /// Get parsed repository blacklist configuration + /// + /// This method assumes config has been validated - call Config::validate() first! + pub fn blacklist_config(&self) -> BlacklistConfig { + let blacklist = WhitelistEntry::parse_whitelist(&self.repository_blacklist); + BlacklistConfig { blacklist } + } + /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { @@ -583,6 +646,7 @@ impl Config { archive_whitelist: String::new(), archive_read_only: None, repository_whitelist: String::new(), + repository_blacklist: String::new(), } } } @@ -1105,4 +1169,83 @@ mod tests { .to_string() .contains("relay_owner_nsec not set")); } + + #[test] + fn test_blacklist_config_parsing() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = Config { + repository_blacklist: format!("{},bitcoin-core", test_npub), + ..Config::for_testing() + }; + let blacklist_config = config.blacklist_config(); + assert_eq!(blacklist_config.blacklist.len(), 2); + assert!(blacklist_config.enabled()); + } + + #[test] + fn test_blacklist_config_empty() { + let config = Config::for_testing(); + let blacklist_config = config.blacklist_config(); + assert!(blacklist_config.blacklist.is_empty()); + assert!(!blacklist_config.enabled()); + } + + #[test] + fn test_blacklist_check_npub() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Pubkey(test_npub.clone())], + }; + + let result = config.check(&test_npub, "any-repo"); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains("owner")); + assert!(reason.contains(&test_npub)); + } + + #[test] + fn test_blacklist_check_identifier() { + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], + }; + + let result = config.check("npub1alice", "banned-repo"); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains("identifier")); + assert!(reason.contains("banned-repo")); + } + + #[test] + fn test_blacklist_check_repository() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Repository { + npub: test_npub.clone(), + identifier: "specific-repo".to_string(), + }], + }; + + let result = config.check(&test_npub, "specific-repo"); + assert!(result.is_some()); + let reason = result.unwrap(); + assert!(reason.contains(&test_npub)); + assert!(reason.contains("specific-repo")); + } + + #[test] + fn test_blacklist_check_not_blacklisted() { + let keys = Keys::generate(); + let test_npub = keys.public_key().to_bech32().unwrap(); + let config = BlacklistConfig { + blacklist: vec![WhitelistEntry::Identifier("banned-repo".to_string())], + }; + + let result = config.check(&test_npub, "allowed-repo"); + assert!(result.is_none()); + } } diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 3b4ef25..39014da 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -366,6 +366,9 @@ impl RepositoryState { /// - AcceptArchive: Announcement matches archive config (GRASP-05) /// - Reject: Validation failed /// +/// Blacklist takes precedence over all whitelists: +/// - If blacklisted, always reject with specific reason (npub/identifier/npub+identifier) +/// /// When archive_read_only is true: /// - ONLY accept announcements matching archive whitelist/all /// - REJECT announcements listing our service but not in whitelist (read-only sync mode) @@ -403,10 +406,16 @@ pub fn validate_announcement( // Get validated configs (config.validate() must be called at startup) let archive_config = config.archive_config(); let repository_config = config.repository_config(); + let blacklist_config = config.blacklist_config(); let npub = announcement.owner_npub(); let lists_service = announcement.lists_service(&config.domain); + // Check blacklist FIRST - it overrides everything + if let Some(reason) = blacklist_config.check(&npub, &announcement.identifier) { + return AnnouncementResult::Reject(reason); + } + // GRASP-01: Normal mode - accept if announcement lists our service AND matches repository whitelist (if enabled) if lists_service && !archive_config.read_only { // Check repository whitelist if enabled @@ -1309,4 +1318,182 @@ mod tests { let result = validate_announcement(&event, &config); assert!(matches!(result, AnnouncementResult::Reject(_))); } + + #[test] + fn test_blacklist_rejects_npub() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for this npub + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: npub.clone(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("owner")); + assert!(reason.contains(&npub)); + } else { + panic!("Expected Reject, got {:?}", result); + } + } + + #[test] + fn test_blacklist_rejects_identifier() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "banned-repo", + vec!["https://gitnostr.com/alice/banned-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for this identifier + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: "banned-repo".to_string(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains("identifier")); + assert!(reason.contains("banned-repo")); + } else { + panic!("Expected Reject, got {:?}", result); + } + } + + #[test] + fn test_blacklist_rejects_specific_repository() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "specific-repo", + vec!["https://gitnostr.com/alice/specific-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for this specific repo + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: format!("{}/specific-repo", npub), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + if let AnnouncementResult::Reject(reason) = result { + assert!(reason.contains(&npub)); + assert!(reason.contains("specific-repo")); + } else { + panic!("Expected Reject, got {:?}", result); + } + } + + #[test] + fn test_blacklist_overrides_repository_whitelist() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://gitnostr.com/alice/test-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with both whitelist and blacklist - blacklist should win + let config = Config { + domain: "gitnostr.com".to_string(), + repository_whitelist: npub.clone(), + repository_blacklist: npub.clone(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_blacklist_overrides_archive_whitelist() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + let npub = keys.public_key().to_bech32().unwrap(); + + // Create announcement that does NOT list our service + let event = create_announcement_event( + &keys, + "test-repo", + vec!["https://other-service.com/alice/test-repo.git"], + vec!["wss://other-service.com"], + ); + + // Config with archive whitelist and blacklist - blacklist should win + let config = Config { + domain: "gitnostr.com".to_string(), + archive_whitelist: npub.clone(), + archive_read_only: Some(false), + repository_blacklist: npub.clone(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Reject(_))); + } + + #[test] + fn test_blacklist_allows_non_blacklisted() { + use crate::config::Config; + use crate::nostr::policy::AnnouncementResult; + + let keys = create_test_keys(); + + // Create announcement that lists our service + let event = create_announcement_event( + &keys, + "allowed-repo", + vec!["https://gitnostr.com/alice/allowed-repo.git"], + vec!["wss://gitnostr.com"], + ); + + // Config with blacklist for different identifier + let config = Config { + domain: "gitnostr.com".to_string(), + repository_blacklist: "banned-repo".to_string(), + ..Config::for_testing() + }; + + let result = validate_announcement(&event, &config); + assert!(matches!(result, AnnouncementResult::Accept)); + } } -- cgit v1.2.3