From 7cc5d37cbf4f02f0bb7eee6342dc1ede5a841a7b Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 9 Jan 2026 07:57:54 +0000 Subject: feat: replace owner-npub with relay-owner-nsec for persistent operator identity Replace the owner-npub configuration option with relay-owner-nsec to provide a persistent cryptographic identity for the relay operator. This addresses NIP-42 authentication requirements discovered during sync debugging. Motivation: - Some relays (e.g., relay.damus.io) require NIP-42 authentication for advanced features like NIP-77 negentropy sync - Previously used random ephemeral keys per connection, providing no persistent identity - Other relays can now recognize us by pubkey for reputation-based rate limiting - Ensures consistency between NIP-11 pubkey and authentication key Changes: - Config: relay_owner_nsec with auto-load/generate from .relay-owner.nsec - NIP-11: Pubkey derived from nsec instead of separate npub field - Sync: RelayConnection now uses operator keys for NIP-42 auth - Docs: Updated README, .env.example, and added .relay-owner.nsec to gitignore Key Features: - Auto-generates key on first run and saves to .relay-owner.nsec - Loads existing key from file on subsequent runs - Can override via CLI flag or environment variable - Enables reputation building across relay network - Future-ready for event signing and WoT calculations Testing: - 225/232 tests passing (7 pre-existing purgatory failures unrelated) - Verified key generation, loading, and NIP-11 derivation - Release build successful Related: work/sync-debug-analysis.md, work/relay-owner-nsec-implementation.md --- .env.example | 14 ++++-- .gitignore | 3 ++ README.md | 8 ++-- src/config.rs | 100 +++++++++++++++++++++++++++++++++++++------ src/http/nip11.rs | 53 +++++++---------------- src/sync/mod.rs | 6 ++- src/sync/relay_connection.rs | 22 ++++++---- 7 files changed, 139 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 7545d03..95db3c2 100644 --- a/.env.example +++ b/.env.example @@ -30,10 +30,16 @@ # RELAY INFORMATION (NIP-11) # ============================================================================ -# Owner's npub (optional, for relay info in NIP-11) -# CLI: --owner-npub -# Default: (none) -# NGIT_OWNER_NPUB=npub1... +# Relay operator's nsec (private key) for signing and authentication +# Used for: +# - NIP-11 relay information document (pubkey field derived from this nsec) +# - NIP-42 authentication when syncing from other relays +# - Future: signing events, WoT-based rate limiting of syncing relays +# +# CLI: --relay-owner-nsec +# Default: Loaded from/saved to .relay-owner.nsec file in current directory +# If file doesn't exist, a new key is generated and saved automatically +# NGIT_RELAY_OWNER_NSEC=nsec1... # Relay name shown in NIP-11 information document # CLI: --relay-name diff --git a/.gitignore b/.gitignore index ef2cbe1..33879b8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,8 @@ work/* .env data/ +# Relay operator private key (auto-generated if not present) +.relay-owner.nsec + # direnv directory - used by nix .direnv \ No newline at end of file diff --git a/README.md b/README.md index b8bff09..7077bc5 100644 --- a/README.md +++ b/README.md @@ -287,10 +287,10 @@ This means CLI flags always take precedence over environment variables, which ta ngit-grasp --help # Run with CLI flags (override everything else) -ngit-grasp --domain relay.example.com --owner-npub npub1... --bind-address 0.0.0.0:8080 +ngit-grasp --domain relay.example.com --relay-owner-nsec nsec1... --bind-address 0.0.0.0:8080 # Mix CLI flags with environment variables -NGIT_OWNER_NPUB=npub1... ngit-grasp --domain relay.example.com +NGIT_RELAY_OWNER_NSEC=nsec1... ngit-grasp --domain relay.example.com ``` ### Configuration Options @@ -300,7 +300,7 @@ NGIT_OWNER_NPUB=npub1... ngit-grasp --domain relay.example.com | Option | CLI Flag | Environment Variable | Default | | ----------------- | --------------------- | ------------------------ | -------------------------------------------- | | Domain | `--domain` | `NGIT_DOMAIN` | (required) | -| Owner npub | `--owner-npub` | `NGIT_OWNER_NPUB` | (optional) | +| Relay owner nsec | `--relay-owner-nsec` | `NGIT_RELAY_OWNER_NSEC` | `.relay-owner.nsec` file (auto-generated) | | Relay name | `--relay-name` | `NGIT_RELAY_NAME` | `${domain} grasp relay` | | Relay description | `--relay-description` | `NGIT_RELAY_DESCRIPTION` | `Git Nostr Relay - a grasp implementation` | | Git data path | `--git-data-path` | `NGIT_GIT_DATA_PATH` | `./data/git` (temp dir for memory backend) | @@ -339,7 +339,7 @@ NGIT_OWNER_NPUB=npub1... ngit-grasp --domain relay.example.com ```bash # Using environment variables (recommended for production) export NGIT_DOMAIN=gitnostr.com -export NGIT_OWNER_NPUB=npub1... +export NGIT_RELAY_OWNER_NSEC=nsec1... # Or let it auto-generate from .relay-owner.nsec export NGIT_BIND_ADDRESS=0.0.0.0:8080 export NGIT_DATABASE_BACKEND=lmdb diff --git a/src/config.rs b/src/config.rs index 94035e4..c4a7b6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,9 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; +use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; /// Database backend type for the relay #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] @@ -40,9 +43,18 @@ pub struct Config { #[arg(long, env = "NGIT_DOMAIN")] pub domain: String, - /// Owner's npub (optional, for relay info in NIP-11) - #[arg(long, env = "NGIT_OWNER_NPUB")] - pub owner_npub: Option, + /// Relay operator's nsec (private key) for signing and authentication + /// + /// Used for: + /// - NIP-11 relay information document (pubkey field derived from this nsec) + /// - NIP-42 authentication when syncing from other relays + /// - Future: signing events, WoT-based rate limiting of syncing relays + /// + /// If not provided via CLI/env, will be loaded from/saved to `.relay-owner.nsec` file + /// in the current directory. If the file doesn't exist, a new key will be generated + /// and saved automatically. + #[arg(long, env = "NGIT_RELAY_OWNER_NSEC")] + pub relay_owner_nsec: Option, /// Relay name for NIP-11 information document (defaults to "${domain} grasp relay") #[arg(long = "relay-name", env = "NGIT_RELAY_NAME")] @@ -127,6 +139,9 @@ pub struct Config { } impl Config { + /// Path to the relay owner key file + const RELAY_OWNER_KEY_FILE: &'static str = ".relay-owner.nsec"; + /// Load configuration from CLI args, environment variables, and defaults. /// /// Priority (highest to lowest): @@ -139,11 +154,66 @@ impl Config { dotenvy::dotenv().ok(); // Parse CLI args (clap automatically handles env var fallback) - let config = Self::parse(); + let mut config = Self::parse(); + + // If relay_owner_nsec not provided, load from file or generate + if config.relay_owner_nsec.is_none() { + config.relay_owner_nsec = Some(Self::load_or_generate_relay_owner_key()?); + } Ok(config) } + /// Load relay owner key from file, or generate and save a new one + fn load_or_generate_relay_owner_key() -> Result { + let key_path = PathBuf::from(Self::RELAY_OWNER_KEY_FILE); + + // Try to load existing key + if key_path.exists() { + let nsec = fs::read_to_string(&key_path) + .context("Failed to read relay owner key file")? + .trim() + .to_string(); + + // Validate it's a valid nsec + Keys::parse(&nsec).context("Invalid nsec in relay owner key file")?; + + tracing::info!( + "Loaded relay owner key from {}", + key_path.display() + ); + return Ok(nsec); + } + + // Generate new key + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32()?; + + // Save to file + fs::write(&key_path, &nsec) + .context("Failed to write relay owner key file")?; + + tracing::info!( + "Generated new relay owner key and saved to {}", + key_path.display() + ); + + Ok(nsec) + } + + /// Get the relay owner's Keys object + pub fn relay_owner_keys(&self) -> Result { + let nsec = self.relay_owner_nsec.as_ref() + .context("relay_owner_nsec not set (should be set by Config::load())")?; + Keys::parse(nsec).context("Invalid relay_owner_nsec") + } + + /// Get the relay owner's public key (npub format) for NIP-11 + pub fn relay_owner_npub(&self) -> Result { + let keys = self.relay_owner_keys()?; + Ok(keys.public_key().to_bech32()?) + } + /// Get relay name (defaults to "${domain} grasp relay" if not set) pub fn relay_name(&self) -> String { self.relay_name_override @@ -167,9 +237,13 @@ impl Config { /// Create config for testing #[cfg(test)] pub fn for_testing() -> Self { + // Generate a test key deterministically for consistent tests + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().expect("Failed to generate test nsec"); + Self { domain: "localhost:8080".to_string(), - owner_npub: Some("npub1test".to_string()), + relay_owner_nsec: Some(nsec), relay_name_override: Some("test relay".to_string()), relay_description: "test description".to_string(), git_data_path: "./test_data/git".to_string(), @@ -256,12 +330,14 @@ mod tests { } #[test] - fn test_owner_npub_optional() { - let config = Config { - owner_npub: None, - ..Config::for_testing() - }; - assert!(config.owner_npub.is_none()); + fn test_relay_owner_keys() { + let config = Config::for_testing(); + let keys = config.relay_owner_keys().expect("Should have valid keys"); + let npub = config.relay_owner_npub().expect("Should derive npub"); + + // Verify the npub matches the keys + assert_eq!(npub, keys.public_key().to_bech32().unwrap()); + assert!(npub.starts_with("npub1")); } #[test] diff --git a/src/http/nip11.rs b/src/http/nip11.rs index 7df8306..cf31cf3 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -59,7 +59,7 @@ impl RelayInformationDocument { Self { name: config.relay_name(), description: config.relay_description.clone(), - pubkey: config.owner_npub.clone(), + pubkey: config.relay_owner_npub().ok(), contact: None, // Could be added to config if needed supported_nips: vec![ 1, // NIP-01: Basic protocol flow @@ -93,30 +93,21 @@ mod tests { #[test] fn test_relay_information_document_structure() { - let config = Config { - domain: "relay.example.com".to_string(), - owner_npub: Some("npub1test".to_string()), - relay_name_override: Some("Test Relay".to_string()), - relay_description: "A test relay".to_string(), - git_data_path: "./data/git".to_string(), - relay_data_path: "./data/relay".to_string(), - bind_address: "127.0.0.1:8080".to_string(), - database_backend: crate::config::DatabaseBackend::Memory, - metrics_enabled: true, - metrics_connection_per_ip_abuse_threshold: 10, - metrics_top_n_repos: 10, - sync_bootstrap_relay_url: None, - sync_max_backoff_secs: 3600, - sync_disconnect_check_interval_secs: 60, - sync_base_backoff_secs: 5, - sync_disable_negentropy: false, - }; + let mut config = Config::for_testing(); + config.domain = "relay.example.com".to_string(); + config.relay_name_override = Some("Test Relay".to_string()); + config.relay_description = "A test relay".to_string(); let doc = RelayInformationDocument::from_config(&config); assert_eq!(doc.name, "Test Relay"); assert_eq!(doc.description, "A test relay"); - assert_eq!(doc.pubkey, Some("npub1test".to_string())); + + // Verify pubkey is present and is a valid npub + assert!(doc.pubkey.is_some()); + let pubkey = doc.pubkey.unwrap(); + assert!(pubkey.starts_with("npub1")); + assert!(doc.supported_nips.contains(&1)); assert!(doc.supported_nips.contains(&11)); assert!(doc.supported_nips.contains(&34)); @@ -132,24 +123,10 @@ mod tests { #[test] fn test_relay_information_document_json() { - let config = Config { - domain: "relay.example.com".to_string(), - owner_npub: Some("npub1test".to_string()), - relay_name_override: Some("Test Relay".to_string()), - relay_description: "A test relay".to_string(), - git_data_path: "./data/git".to_string(), - relay_data_path: "./data/relay".to_string(), - bind_address: "127.0.0.1:8080".to_string(), - database_backend: crate::config::DatabaseBackend::Memory, - metrics_enabled: true, - metrics_connection_per_ip_abuse_threshold: 10, - metrics_top_n_repos: 10, - sync_bootstrap_relay_url: None, - sync_max_backoff_secs: 3600, - sync_disconnect_check_interval_secs: 60, - sync_base_backoff_secs: 5, - sync_disable_negentropy: false, - }; + let mut config = Config::for_testing(); + config.domain = "relay.example.com".to_string(); + config.relay_name_override = Some("Test Relay".to_string()); + config.relay_description = "A test relay".to_string(); let doc = RelayInformationDocument::from_config(&config); let json = doc.to_json().expect("Failed to serialize to JSON"); diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 280f857..6da2644 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1396,8 +1396,12 @@ impl SyncManager { async fn register_relay(&mut self, relay_url: String) { // Create RelayConnection if not exists if !self.connections.contains_key(&relay_url) { + // Get relay owner keys for NIP-42 authentication + let keys = self.config.relay_owner_keys() + .expect("relay_owner_keys should be available"); + let connection = - RelayConnection::new_with_database(relay_url.clone(), Arc::clone(&self.database)); + RelayConnection::new_with_database(relay_url.clone(), Arc::clone(&self.database), keys); self.connections.insert(relay_url.clone(), connection); tracing::debug!(relay = %relay_url, "Registered new relay connection"); } diff --git a/src/sync/relay_connection.rs b/src/sync/relay_connection.rs index 21d864d..d0090c8 100644 --- a/src/sync/relay_connection.rs +++ b/src/sync/relay_connection.rs @@ -106,9 +106,10 @@ impl RelayConnection { /// /// # Arguments /// * `url` - The relay URL to connect to (with or without scheme, e.g., "relay.example.com" or "wss://relay.example.com") - pub fn new(url: String) -> Self { + /// * `keys` - Cryptographic keys for NIP-42 authentication (typically the relay operator's keys) + pub fn new(url: String, keys: Keys) -> Self { let normalized_url = Self::normalize_url(&url); - let client = Client::default(); + let client = Client::new(keys); Self { url: normalized_url, client, @@ -122,9 +123,10 @@ impl RelayConnection { /// # Arguments /// * `url` - The relay URL to connect to (with or without scheme, e.g., "relay.example.com" or "wss://relay.example.com") /// * `database` - Shared database for local event comparison during negentropy sync - pub fn new_with_database(url: String, database: SharedDatabase) -> Self { + /// * `keys` - Cryptographic keys for NIP-42 authentication (typically the relay operator's keys) + pub fn new_with_database(url: String, database: SharedDatabase, keys: Keys) -> Self { let normalized_url = Self::normalize_url(&url); - let client = Client::default(); + let client = Client::new(keys); Self { url: normalized_url, client, @@ -553,19 +555,22 @@ mod tests { #[test] fn test_new_normalizes_url() { - let conn = RelayConnection::new("relay.example.com".to_string()); + let keys = Keys::generate(); + let conn = RelayConnection::new("relay.example.com".to_string(), keys); assert_eq!(conn.url(), "wss://relay.example.com"); } #[test] fn test_new_preserves_wss_scheme() { - let conn = RelayConnection::new("wss://relay.example.com".to_string()); + let keys = Keys::generate(); + let conn = RelayConnection::new("wss://relay.example.com".to_string(), keys); assert_eq!(conn.url(), "wss://relay.example.com"); } #[test] fn test_new_preserves_ws_scheme() { - let conn = RelayConnection::new("ws://relay.example.com".to_string()); + let keys = Keys::generate(); + let conn = RelayConnection::new("ws://relay.example.com".to_string(), keys); assert_eq!(conn.url(), "ws://relay.example.com"); } @@ -573,7 +578,8 @@ mod tests { fn test_new_with_database_normalizes_url() { // This test just verifies the URL normalization works // We can't easily test with_database without a real database - let conn = RelayConnection::new("git.shakespeare.diy".to_string()); + let keys = Keys::generate(); + let conn = RelayConnection::new("git.shakespeare.diy".to_string(), keys); assert_eq!(conn.url(), "wss://git.shakespeare.diy"); } -- cgit v1.2.3