diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-09 07:57:54 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-09 07:57:54 +0000 |
| commit | 7cc5d37cbf4f02f0bb7eee6342dc1ede5a841a7b (patch) | |
| tree | 62d3dcf291a7ca67d18cf397b448fb98d62553ba /src/config.rs | |
| parent | 6bc2d70f6dd351521e522cc4d0f1ac188848ad26 (diff) | |
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
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 100 |
1 files changed, 88 insertions, 12 deletions
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 @@ | |||
| 1 | use anyhow::Result; | 1 | use anyhow::{Context, Result}; |
| 2 | use clap::{Parser, ValueEnum}; | 2 | use clap::{Parser, ValueEnum}; |
| 3 | use nostr_sdk::prelude::*; | ||
| 3 | use serde::{Deserialize, Serialize}; | 4 | use serde::{Deserialize, Serialize}; |
| 5 | use std::fs; | ||
| 6 | use std::path::PathBuf; | ||
| 4 | 7 | ||
| 5 | /// Database backend type for the relay | 8 | /// Database backend type for the relay |
| 6 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] | 9 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, ValueEnum)] |
| @@ -40,9 +43,18 @@ pub struct Config { | |||
| 40 | #[arg(long, env = "NGIT_DOMAIN")] | 43 | #[arg(long, env = "NGIT_DOMAIN")] |
| 41 | pub domain: String, | 44 | pub domain: String, |
| 42 | 45 | ||
| 43 | /// Owner's npub (optional, for relay info in NIP-11) | 46 | /// Relay operator's nsec (private key) for signing and authentication |
| 44 | #[arg(long, env = "NGIT_OWNER_NPUB")] | 47 | /// |
| 45 | pub owner_npub: Option<String>, | 48 | /// Used for: |
| 49 | /// - NIP-11 relay information document (pubkey field derived from this nsec) | ||
| 50 | /// - NIP-42 authentication when syncing from other relays | ||
| 51 | /// - Future: signing events, WoT-based rate limiting of syncing relays | ||
| 52 | /// | ||
| 53 | /// If not provided via CLI/env, will be loaded from/saved to `.relay-owner.nsec` file | ||
| 54 | /// in the current directory. If the file doesn't exist, a new key will be generated | ||
| 55 | /// and saved automatically. | ||
| 56 | #[arg(long, env = "NGIT_RELAY_OWNER_NSEC")] | ||
| 57 | pub relay_owner_nsec: Option<String>, | ||
| 46 | 58 | ||
| 47 | /// Relay name for NIP-11 information document (defaults to "${domain} grasp relay") | 59 | /// Relay name for NIP-11 information document (defaults to "${domain} grasp relay") |
| 48 | #[arg(long = "relay-name", env = "NGIT_RELAY_NAME")] | 60 | #[arg(long = "relay-name", env = "NGIT_RELAY_NAME")] |
| @@ -127,6 +139,9 @@ pub struct Config { | |||
| 127 | } | 139 | } |
| 128 | 140 | ||
| 129 | impl Config { | 141 | impl Config { |
| 142 | /// Path to the relay owner key file | ||
| 143 | const RELAY_OWNER_KEY_FILE: &'static str = ".relay-owner.nsec"; | ||
| 144 | |||
| 130 | /// Load configuration from CLI args, environment variables, and defaults. | 145 | /// Load configuration from CLI args, environment variables, and defaults. |
| 131 | /// | 146 | /// |
| 132 | /// Priority (highest to lowest): | 147 | /// Priority (highest to lowest): |
| @@ -139,11 +154,66 @@ impl Config { | |||
| 139 | dotenvy::dotenv().ok(); | 154 | dotenvy::dotenv().ok(); |
| 140 | 155 | ||
| 141 | // Parse CLI args (clap automatically handles env var fallback) | 156 | // Parse CLI args (clap automatically handles env var fallback) |
| 142 | let config = Self::parse(); | 157 | let mut config = Self::parse(); |
| 158 | |||
| 159 | // If relay_owner_nsec not provided, load from file or generate | ||
| 160 | if config.relay_owner_nsec.is_none() { | ||
| 161 | config.relay_owner_nsec = Some(Self::load_or_generate_relay_owner_key()?); | ||
| 162 | } | ||
| 143 | 163 | ||
| 144 | Ok(config) | 164 | Ok(config) |
| 145 | } | 165 | } |
| 146 | 166 | ||
| 167 | /// Load relay owner key from file, or generate and save a new one | ||
| 168 | fn load_or_generate_relay_owner_key() -> Result<String> { | ||
| 169 | let key_path = PathBuf::from(Self::RELAY_OWNER_KEY_FILE); | ||
| 170 | |||
| 171 | // Try to load existing key | ||
| 172 | if key_path.exists() { | ||
| 173 | let nsec = fs::read_to_string(&key_path) | ||
| 174 | .context("Failed to read relay owner key file")? | ||
| 175 | .trim() | ||
| 176 | .to_string(); | ||
| 177 | |||
| 178 | // Validate it's a valid nsec | ||
| 179 | Keys::parse(&nsec).context("Invalid nsec in relay owner key file")?; | ||
| 180 | |||
| 181 | tracing::info!( | ||
| 182 | "Loaded relay owner key from {}", | ||
| 183 | key_path.display() | ||
| 184 | ); | ||
| 185 | return Ok(nsec); | ||
| 186 | } | ||
| 187 | |||
| 188 | // Generate new key | ||
| 189 | let keys = Keys::generate(); | ||
| 190 | let nsec = keys.secret_key().to_bech32()?; | ||
| 191 | |||
| 192 | // Save to file | ||
| 193 | fs::write(&key_path, &nsec) | ||
| 194 | .context("Failed to write relay owner key file")?; | ||
| 195 | |||
| 196 | tracing::info!( | ||
| 197 | "Generated new relay owner key and saved to {}", | ||
| 198 | key_path.display() | ||
| 199 | ); | ||
| 200 | |||
| 201 | Ok(nsec) | ||
| 202 | } | ||
| 203 | |||
| 204 | /// Get the relay owner's Keys object | ||
| 205 | pub fn relay_owner_keys(&self) -> Result<Keys> { | ||
| 206 | let nsec = self.relay_owner_nsec.as_ref() | ||
| 207 | .context("relay_owner_nsec not set (should be set by Config::load())")?; | ||
| 208 | Keys::parse(nsec).context("Invalid relay_owner_nsec") | ||
| 209 | } | ||
| 210 | |||
| 211 | /// Get the relay owner's public key (npub format) for NIP-11 | ||
| 212 | pub fn relay_owner_npub(&self) -> Result<String> { | ||
| 213 | let keys = self.relay_owner_keys()?; | ||
| 214 | Ok(keys.public_key().to_bech32()?) | ||
| 215 | } | ||
| 216 | |||
| 147 | /// Get relay name (defaults to "${domain} grasp relay" if not set) | 217 | /// Get relay name (defaults to "${domain} grasp relay" if not set) |
| 148 | pub fn relay_name(&self) -> String { | 218 | pub fn relay_name(&self) -> String { |
| 149 | self.relay_name_override | 219 | self.relay_name_override |
| @@ -167,9 +237,13 @@ impl Config { | |||
| 167 | /// Create config for testing | 237 | /// Create config for testing |
| 168 | #[cfg(test)] | 238 | #[cfg(test)] |
| 169 | pub fn for_testing() -> Self { | 239 | pub fn for_testing() -> Self { |
| 240 | // Generate a test key deterministically for consistent tests | ||
| 241 | let keys = Keys::generate(); | ||
| 242 | let nsec = keys.secret_key().to_bech32().expect("Failed to generate test nsec"); | ||
| 243 | |||
| 170 | Self { | 244 | Self { |
| 171 | domain: "localhost:8080".to_string(), | 245 | domain: "localhost:8080".to_string(), |
| 172 | owner_npub: Some("npub1test".to_string()), | 246 | relay_owner_nsec: Some(nsec), |
| 173 | relay_name_override: Some("test relay".to_string()), | 247 | relay_name_override: Some("test relay".to_string()), |
| 174 | relay_description: "test description".to_string(), | 248 | relay_description: "test description".to_string(), |
| 175 | git_data_path: "./test_data/git".to_string(), | 249 | git_data_path: "./test_data/git".to_string(), |
| @@ -256,12 +330,14 @@ mod tests { | |||
| 256 | } | 330 | } |
| 257 | 331 | ||
| 258 | #[test] | 332 | #[test] |
| 259 | fn test_owner_npub_optional() { | 333 | fn test_relay_owner_keys() { |
| 260 | let config = Config { | 334 | let config = Config::for_testing(); |
| 261 | owner_npub: None, | 335 | let keys = config.relay_owner_keys().expect("Should have valid keys"); |
| 262 | ..Config::for_testing() | 336 | let npub = config.relay_owner_npub().expect("Should derive npub"); |
| 263 | }; | 337 | |
| 264 | assert!(config.owner_npub.is_none()); | 338 | // Verify the npub matches the keys |
| 339 | assert_eq!(npub, keys.public_key().to_bech32().unwrap()); | ||
| 340 | assert!(npub.starts_with("npub1")); | ||
| 265 | } | 341 | } |
| 266 | 342 | ||
| 267 | #[test] | 343 | #[test] |