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
This commit is contained in:
DanConwayDev
2026-01-09 07:57:54 +00:00
parent 6bc2d70f6d
commit 7cc5d37cbf
7 changed files with 139 additions and 67 deletions
+10 -4
View File
@@ -30,10 +30,16 @@
# RELAY INFORMATION (NIP-11)
# ============================================================================
# Owner's npub (optional, for relay info in NIP-11)
# CLI: --owner-npub <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 <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 <name>
+3
View File
@@ -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
+4 -4
View File
@@ -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
+88 -12
View File
@@ -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<String>,
/// 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<String>,
/// 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<String> {
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<Keys> {
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<String> {
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]
+15 -38
View File
@@ -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");
+5 -1
View File
@@ -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");
}
+14 -8
View File
@@ -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");
}