mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
+10
-4
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user