diff --git a/.env.example b/.env.example index e015275..47f94b8 100644 --- a/.env.example +++ b/.env.example @@ -37,11 +37,12 @@ # NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 # ============================================================================ -# RELAY INFORMATION (NIP-11) +# RELAY IDENTITY AND INFORMATION (NIP-05 / NIP-11) # ============================================================================ # Relay operator's nsec (private key) for signing and authentication # Used for: +# - NIP-05 _@domain well-known identity when served at the domain root # - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index aefb13e..cca496f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Serve the relay owner's public key as the NIP-05 `_@domain` identity from + `/.well-known/nostr.json`. NIP-11 advertises NIP-05 only when requested at + the domain root (`/`); relays mounted below a path do not claim support. - Add fixed-cardinality aggregate metrics for important long-lived sync state and document the producer, cleanup owner, bound, and terminal behavior of every peer-influenced transient subsystem. diff --git a/README.md b/README.md index 01a28c9..41632c8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A [GRASP](https://gitworkshop.dev/danconwaydev.com/grasp) (Git Relays Authorized - **Git Smart HTTP Backend**: Serves Git repositories over HTTP - **Nostr Relay**: Stores and validates repository announcements and state events +- **Root Nostr Identity**: Serves `_@domain` through the NIP-05 well-known endpoint when mounted at the domain root - **Integrated Authorization**: Validates Git pushes against Nostr state events without requiring external hooks Unlike the reference implementation ([ngit-relay](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-relay)) which uses nginx + git-http-backend + pre-receive hooks + Khatru (Go), `ngit-grasp` provides a unified Rust service that handles both Git and Nostr protocols natively. @@ -119,6 +120,7 @@ See [GRASP-02 Proactive Sync](docs/explanation/grasp-02-proactive-sync.md) for f - ✅ Git capabilities: `allow-tip-sha1-in-want`, `allow-reachable-sha1-in-want`, `uploadpack.allowFilter` - ✅ CORS support for web-based Git clients - ✅ NIP-11 relay information document +- ✅ NIP-05 `_@domain` identity backed by the relay operator public key when the relay is mounted at `/` - ✅ **Purgatory**: Events without git data held for 30 minutes, auto-released when data arrives - ✅ **Deletion Lifecycle**: NIP-09/NIP-62 support with GRASP-aware cascade deletion, related git-data archival/removal, holding/archive recovery, and archival-relay disrespector mode @@ -672,6 +674,7 @@ ngit-grasp/ │ ├── http/ │ │ ├── mod.rs # HTTP module │ │ ├── landing.rs # Landing page handler +│ │ ├── nip05.rs # NIP-05 root identity document │ │ └── nip11.rs # NIP-11 relay info document │ └── metrics/ │ ├── mod.rs # Prometheus metrics (Git, Nostr, Sync) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 12ad859..18874e9 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -95,7 +95,8 @@ nostr-lmdb = "0.45.0-alpha.3" - Route HTTP requests to appropriate handlers - WebSocket upgrade for Nostr relay at `/` - Git Smart HTTP endpoints at `//.git/*` -- Landing pages and NIP-11 document serving +- Landing pages, the root-path-only NIP-05 `_@domain` well-known mapping, and + path-aware NIP-11 document serving that omits NIP-05 below the domain root - CORS headers on all responses (GRASP-01 requirement) **Key Implementation Details:** diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9b89b81..a7b5e6e 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -148,6 +148,8 @@ NGIT_RELAY_OWNER_NSEC=nsec1... **Used for:** +- Deriving the `_@domain` NIP-05 identity served from `/.well-known/nostr.json` + when the relay itself is available at the domain root (`/`) - Deriving the operator pubkey in the NIP-11 relay information document - NIP-42 authentication when synchronizing from other relays diff --git a/nix/module.nix b/nix/module.nix index 19f7fe3..0469f01 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -85,7 +85,9 @@ let default = null; example = "/run/agenix/ngit-grasp-relay-owner-nsec"; description = '' - Runtime secret file containing the relay owner's nsec (private key). + Runtime secret file containing the relay owner's nsec (private key), + used for the NIP-05 root identity when served at the domain root, + NIP-11 relay information, and relay authentication. The service receives it as a systemd credential named `${relayOwnerNsecCredential}`, so the secret is never placed in the process command line. @@ -103,7 +105,9 @@ let default = null; example = "nsec1..."; description = '' - Relay owner's nsec (private key) for signing and authentication. + Relay owner's nsec (private key) for the NIP-05 root identity when + served at the domain root, NIP-11 relay information, signing, and + authentication. Less secure than relayOwnerNsecFile as it ends up in nix store. Only used if relayOwnerNsecFile is not set. ''; diff --git a/src/config.rs b/src/config.rs index 8967db9..49416a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -328,6 +328,7 @@ pub struct Config { /// Relay operator's nsec (private key) for signing and authentication. /// /// Used for: + /// - NIP-05 `_@domain` well-known identity when served at the domain root /// - 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 diff --git a/src/http/mod.rs b/src/http/mod.rs index d0ba415..b75568c 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -3,6 +3,7 @@ //! Provides hyper HTTP server with WebSocket upgrade support for the Nostr relay. mod client_ip; pub mod landing; +pub mod nip05; pub mod nip11; use std::future::Future; @@ -193,6 +194,43 @@ impl Service> for HttpService { }); } + // NIP-05 root identity: `_@domain` resolves to the relay operator's + // public key. This exact-path route must remain ahead of generic NIP-11 + // content negotiation so clients receive the well-known document even + // if they send a broad or unusual Accept header. + if path == "/.well-known/nostr.json" && (method == Method::GET || method == Method::HEAD) { + let method = method.clone(); + let document = nip05::Nip05Document::from_config(&self.config); + + return Box::pin(async move { + match document.and_then(|document| document.to_json()) { + Ok(json) => { + let body = if method == Method::HEAD { + empty_body() + } else { + full_body(json) + }; + Ok( + add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(200) + .header("content-type", "application/json; charset=utf-8") + .body(body) + .unwrap(), + ) + } + Err(error) => { + tracing::error!(%error, "Failed to build NIP-05 root identity document"); + Ok( + add_cors_headers(Response::builder().header("server", "ngit-grasp")) + .status(500) + .body(full_body("Failed to build NIP-05 document")) + .unwrap(), + ) + } + } + }); + } + // GRASP-06: route /prs//.git/* before the standard git URL // parser. When disabled, the path falls through to existing 404 // handling (preserving the discovery-gate contract). @@ -644,7 +682,7 @@ impl Service> for HttpService { .map(|s| s.contains("application/nostr+json")) .unwrap_or(false) { - let doc = nip11::RelayInformationDocument::from_config(&self.config); + let doc = nip11::RelayInformationDocument::from_config_at_path(&self.config, &path); let json = doc.to_json().unwrap_or_else(|e| { tracing::error!("Failed to serialize NIP-11 document: {}", e); "{}".to_string() diff --git a/src/http/nip05.rs b/src/http/nip05.rs new file mode 100644 index 0000000..9d02a8b --- /dev/null +++ b/src/http/nip05.rs @@ -0,0 +1,51 @@ +//! NIP-05 root-identifier document. +//! +//! A relay operator can use `_@domain` as the domain's root Nostr identity. +//! The well-known response maps that identifier to the same public key used +//! by the relay for NIP-11 and NIP-42. + +use std::collections::BTreeMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; + +/// NIP-05 mapping served from `/.well-known/nostr.json`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Nip05Document { + pub names: BTreeMap, +} + +impl Nip05Document { + /// Build the `_@domain` mapping from the configured relay identity. + pub fn from_config(config: &Config) -> Result { + Ok(Self { + names: BTreeMap::from([("_".to_string(), config.relay_owner_pubkey_hex()?)]), + }) + } + + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn root_identifier_uses_relay_owner_pubkey() { + let config = Config::for_testing(); + let expected = config.relay_owner_pubkey_hex().unwrap(); + + let document = Nip05Document::from_config(&config).unwrap(); + + assert_eq!(document.names.len(), 1); + assert_eq!(document.names.get("_"), Some(&expected)); + assert_eq!(expected.len(), 64); + assert!(expected + .chars() + .all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character))); + } +} diff --git a/src/http/nip11.rs b/src/http/nip11.rs index 9bf6036..531951c 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -67,8 +67,17 @@ pub struct RelayLimitation { } impl RelayInformationDocument { - /// Create NIP-11 relay information document from configuration + /// Create a NIP-11 document for a relay served at the domain root. pub fn from_config(config: &Config) -> Self { + Self::from_config_at_path(config, "/") + } + + /// Create a NIP-11 document for the path where the relay was requested. + /// + /// NIP-05 `_@domain` can only resolve a relay served at the domain root, + /// because clients always request `/.well-known/nostr.json`. A relay + /// mounted below any other path must therefore not advertise NIP-05. + pub fn from_config_at_path(config: &Config, relay_path: &str) -> Self { // Get validated configuration (config.validate() must be called at startup) let archive_config = config.archive_config(); let archive_enabled = archive_config.enabled(); @@ -128,6 +137,9 @@ impl RelayInformationDocument { 34, // NIP-34: Git repository announcements 77, // NIP-77: Negentropy sync (reconciliation protocol) ]; + if relay_path == "/" { + nips.push(5); // NIP-05: Root-domain identity + } // NIP-09 (deletion) and NIP-62 (request to vanish) are honoured // only when not running as an archival "disrespector" relay. When // disrespector mode is on the relay stores but ignores deletion / @@ -137,8 +149,8 @@ impl RelayInformationDocument { if !config.deletion_request_disrespector { nips.push(9); // NIP-09: Event deletion requests nips.push(62); // NIP-62: Request to vanish - nips.sort_unstable(); } + nips.sort_unstable(); nips }, software: "https://gitworkshop.dev/danconwaydev.com/ngit-grasp".to_string(), @@ -193,6 +205,7 @@ mod tests { assert!(pubkey.chars().all(|c| c.is_ascii_hexdigit())); assert!(doc.supported_nips.contains(&1)); + assert!(doc.supported_nips.contains(&5)); assert!(doc.supported_nips.contains(&11)); assert!(doc.supported_nips.contains(&34)); assert!(doc.supported_nips.contains(&77)); @@ -400,6 +413,17 @@ mod tests { assert!(doc.supported_nips.contains(&62)); } + #[test] + fn test_nip11_advertises_nip05_only_at_domain_root() { + let config = Config::for_testing(); + + let root_doc = RelayInformationDocument::from_config_at_path(&config, "/"); + let nested_doc = RelayInformationDocument::from_config_at_path(&config, "/relay"); + + assert!(root_doc.supported_nips.contains(&5)); + assert!(!nested_doc.supported_nips.contains(&5)); + } + #[test] fn test_nip11_omits_deletion_in_disrespector_mode() { let mut config = Config::for_testing(); diff --git a/tests/nip05_identity.rs b/tests/nip05_identity.rs new file mode 100644 index 0000000..0782441 --- /dev/null +++ b/tests/nip05_identity.rs @@ -0,0 +1,86 @@ +//! NIP-05 root-identifier integration tests. + +mod common; + +use common::TestRelay; + +#[tokio::test] +async fn well_known_root_identity_matches_relay_pubkey() { + let relay = TestRelay::start().await; + let client = reqwest::Client::new(); + + let response = client + .get(format!( + "http://{}/.well-known/nostr.json?name=_", + relay.domain() + )) + .header("Accept", "application/nostr+json, application/json") + .send() + .await + .expect("request NIP-05 root identity"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("*") + ); + assert!(response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json"))); + + let nip05: serde_json::Value = response.json().await.expect("parse NIP-05 document"); + let root_pubkey = nip05["names"]["_"] + .as_str() + .expect("NIP-05 document should map the root identifier"); + assert_eq!(root_pubkey.len(), 64); + assert!(root_pubkey + .chars() + .all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character))); + + let nip11: serde_json::Value = client + .get(format!("http://{}", relay.domain())) + .header("Accept", "application/nostr+json") + .send() + .await + .expect("request NIP-11 document") + .json() + .await + .expect("parse NIP-11 document"); + + assert_eq!(nip11["pubkey"], root_pubkey); + assert!(nip11["supported_nips"] + .as_array() + .expect("supported_nips should be an array") + .contains(&serde_json::json!(5))); + + let nested_nip11: serde_json::Value = client + .get(format!("http://{}/relay", relay.domain())) + .header("Accept", "application/nostr+json") + .send() + .await + .expect("request NIP-11 document at a non-root relay path") + .json() + .await + .expect("parse nested-path NIP-11 document"); + assert!(!nested_nip11["supported_nips"] + .as_array() + .expect("nested-path supported_nips should be an array") + .contains(&serde_json::json!(5))); + + let nested_response = client + .get(format!( + "http://{}/repository/.well-known/nostr.json?name=_", + relay.domain() + )) + .send() + .await + .expect("request a non-root well-known path"); + assert_eq!(nested_response.status(), reqwest::StatusCode::NOT_FOUND); + + relay.stop().await; +}