diff --git a/.env.example b/.env.example index 47f94b8..cba21cc 100644 --- a/.env.example +++ b/.env.example @@ -299,6 +299,30 @@ # Default: false # NGIT_GRASP06_ENABLE=false +# ============================================================================ +# GRASP-08 PRIVATE SERVICE +# ============================================================================ + +# Require whitelisted authentication for all Nostr WebSocket reads/writes and +# all standard Git Smart HTTP requests. +# Cannot be combined with the intentionally unauthenticated GRASP-06 endpoint. +# +# CLI: --private-mode +# Default: false +# NGIT_PRIVATE_MODE=false + +# Service-wide member whitelist as comma-separated npubs. Every entry is +# validated at startup; malformed entries fail closed. +# +# CLI: --private-members +# Default: (empty; at least one member is required in private mode) +# NGIT_PRIVATE_MEMBERS=npub1alice...,npub1bob... +# +# Canonical external origin signed in Git NIP-98 credentials. Set this when +# TLS terminates at a reverse proxy or a non-loopback service uses plain HTTP. +# It must be an http(s) origin without a path, query, or fragment. +# NGIT_PRIVATE_PUBLIC_ORIGIN=https://private.example + # ============================================================================ # DELETION REQUESTS (NIP-09 AND NIP-62) # ============================================================================ diff --git a/Cargo.toml b/Cargo.toml index c7d4ece..3d439db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ nostr-memory = "0.45.0" # so depend on it directly rather than pulling in a second hash implementation. bitcoin_hashes = "0.14" futures-util = "0.3" +tokio-tungstenite = { version = "0.28", default-features = false } base64 = "0.22" flate2 = "1.0" tar = "0.4" diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 820c63e..7f0db17 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -683,6 +683,34 @@ Optional endpoint at `/prs//.git`, gated on `NGIT_GRASP06_ENAB `/prs/` repos are intentionally isolated from other subsystems: empty-repo cleanup skips the `/prs/` subtree, the proactive-sync subsystem never discovers them because subscriptions are built from DB-resident announcements, and the standard repo landing page guards against ever matching a `/prs/` path. Full design: [GRASP-06 Contributor Pull Request Submission](grasp-06-contributor-pr-submission.md). Operator how-to: [Enable GRASP-06](../how-to/enable-grasp-06.md). +## Private Service Authentication (GRASP-08) + +Private mode is an optional access layer around the normal GRASP runtime. A +single `PrivateAccess` set is shared by the HTTP and WebSocket services. +Repository admission and push authorization remain the GRASP-01 policies; a +private credential proves service membership but never grants push rights. + +For Nostr, Hyper completes the public WebSocket upgrade and a message-level +proxy sends and validates NIP-42 authentication before a connection reaches +`LocalRelay`. Missing authentication uses the `auth-required:` prefix and a +valid authentication by a nonmember uses `restricted:`. Once authenticated, +the proxy bridges messages through an in-memory WebSocket pair to +`LocalRelay`. Keeping the access check outside `nostr-relay-builder` is +necessary because its query and write policies do not receive the +authenticated session pubkey. + +For Git, authentication runs before repository existence checks or request +body collection. The signed NIP-98 event names the canonical repository root +and method `GET`; that credential is reusable for GET, HEAD, and POST requests +to the repository root and Smart HTTP subpaths for its 60-second validity +window. Payload tags and replay protection are intentionally not applied. +Every authentication failure is the same empty `401 Unauthorized` response, +preventing unauthenticated repository enumeration. + +Private mode advertises GRASP-08 plus NIP-42 and NIP-98 in NIP-11. It is +incompatible with GRASP-06 because that extension deliberately exposes an +unauthenticated contributor write surface. + ## Future Extensions ### GRASP-02: Proactive Sync diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a7b5e6e..2c14a85 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -998,6 +998,53 @@ Operators should review the design tradeoffs in [`docs/explanation/grasp-06-cont --- +### GRASP-08 Private Service + +#### `NGIT_PRIVATE_MODE` + +**Description:** Enable authenticated, read-restricted GRASP-08 service mode +**Type:** Boolean +**Default:** `false` +**Required:** No + +When enabled, the relay requires successful NIP-42 authentication by a +whitelisted member before accepting or serving any Nostr events. Standard Git +repository root, `info/refs`, `git-upload-pack`, and `git-receive-pack` +requests require the GRASP-08 repository-scoped NIP-98 credential. + +```bash +NGIT_PRIVATE_MODE=true +``` + +Private mode cannot be combined with `NGIT_GRASP06_ENABLE=true`, because the +GRASP-06 contributor endpoint is intentionally unauthenticated. + +#### `NGIT_PRIVATE_MEMBERS` + +**Description:** Service-wide private-service member whitelist +**Type:** Comma-separated npubs +**Default:** Empty +**Required:** Yes when `NGIT_PRIVATE_MODE=true` + +```bash +NGIT_PRIVATE_MEMBERS=npub1alice...,npub1bob... +``` + +#### `NGIT_PRIVATE_PUBLIC_ORIGIN` + +Optional canonical external HTTP origin for private Git authentication, such +as `https://private.example`. Configure it when TLS terminates at a reverse +proxy or when a non-loopback deployment intentionally uses plain HTTP. The +value must not include a path, query, or fragment. If omitted, the service +retains the legacy inference of HTTP for loopback domains and HTTPS otherwise. + +Whitespace and empty comma-separated elements are ignored. Every non-empty +entry must be a valid npub. Invalid entries fail startup rather than being +silently skipped because this setting controls access, not repository +curation. Changing the environment setting requires a service restart. + +--- + ### Repository Whitelist #### `NGIT_REPOSITORY_WHITELIST` diff --git a/nix/module.nix b/nix/module.nix index 0469f01..7bbd3ba 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -390,6 +390,35 @@ let ''; }; + privateMode = mkOption { + type = types.bool; + default = false; + description = '' + Enable GRASP-08 private-service authentication. All Nostr clients + must authenticate with NIP-42 and all standard Git Smart HTTP + requests must carry a repository-scoped NIP-98 credential. + Cannot be combined with grasp06Enable. + ''; + }; + + privateMembers = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "npub1alice..." "npub1bob..." ]; + description = '' + Service-wide GRASP-08 member whitelist. At least one valid npub is + required when privateMode is enabled. Malformed entries make the + service fail at startup. + ''; + }; + + privatePublicOrigin = mkOption { + type = types.str; + default = ""; + example = "https://private.example"; + description = "Canonical external origin used by private Git NIP-98 credentials."; + }; + repositoryWhitelist = mkOption { type = types.listOf types.str; default = [ ]; @@ -571,6 +600,9 @@ let NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist; NGIT_LOG_LEVEL = cfg.logLevel; NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false"; + NGIT_PRIVATE_MODE = if cfg.privateMode then "true" else "false"; + NGIT_PRIVATE_MEMBERS = concatStringsSep "," cfg.privateMembers; + NGIT_PRIVATE_PUBLIC_ORIGIN = cfg.privatePublicOrigin; NGIT_DELETION_REQUEST_DISRESPECTOR = if cfg.deletionRequestDisrespector then "true" else "false"; NGIT_RELAY_MAX_SUBSCRIPTIONS = toString cfg.relayMaxSubscriptions; diff --git a/src/config.rs b/src/config.rs index 49416a4..15d2337 100644 --- a/src/config.rs +++ b/src/config.rs @@ -591,6 +591,27 @@ pub struct Config { #[arg(long, env = "NGIT_GRASP06_ENABLE", default_value_t = false)] pub grasp06_enable: bool, + /// Enable GRASP-08 private-service authentication. + /// + /// When enabled, every Nostr WebSocket session must authenticate with + /// NIP-42 and every standard Git Smart HTTP request must carry the + /// GRASP-08 repository-scoped NIP-98 credential. + #[arg(long, env = "NGIT_PRIVATE_MODE", default_value_t = false)] + pub private_mode: bool, + + /// Service-wide GRASP-08 member whitelist as comma-separated npubs. + /// + /// Required and fail-closed when private mode is enabled. + #[arg(long, env = "NGIT_PRIVATE_MEMBERS", default_value = "")] + pub private_members: String, + + /// Canonical externally visible origin used by GRASP-08 NIP-98 `u` tags. + /// + /// Set this when TLS terminates upstream or a non-loopback deployment uses + /// plain HTTP. When empty, the origin is inferred from `NGIT_DOMAIN`. + #[arg(long, env = "NGIT_PRIVATE_PUBLIC_ORIGIN", default_value = "")] + pub private_public_origin: String, + /// Repository whitelist: comma-separated list of npub/identifier/npub/identifier entries /// Formats: "npub1...", "npub1.../identifier", "identifier" /// When set, only announcements matching the whitelist AND listing the service are accepted @@ -906,6 +927,38 @@ impl Config { // Validate repository whitelist configuration let repository_whitelist = WhitelistEntry::parse_whitelist(&self.repository_whitelist); + let private_members = self.parse_private_members()?; + if self.private_mode && private_members.is_empty() { + return Err(anyhow!( + "NGIT_PRIVATE_MODE=true requires at least one npub in NGIT_PRIVATE_MEMBERS" + )); + } + if !self.private_mode && !private_members.is_empty() { + return Err(anyhow!( + "NGIT_PRIVATE_MEMBERS requires NGIT_PRIVATE_MODE=true" + )); + } + if self.private_mode && self.grasp06_enable { + return Err(anyhow!( + "NGIT_PRIVATE_MODE=true cannot be combined with NGIT_GRASP06_ENABLE=true because \ + the GRASP-06 contributor endpoint is intentionally unauthenticated" + )); + } + if !self.private_public_origin.trim().is_empty() { + let origin = Url::parse(self.private_public_origin.trim()) + .context("NGIT_PRIVATE_PUBLIC_ORIGIN must be an absolute URL")?; + if !matches!(origin.scheme(), "http" | "https") + || origin.host_str().is_none() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return Err(anyhow!( + "NGIT_PRIVATE_PUBLIC_ORIGIN must be an http(s) origin without a path, query, or fragment" + )); + } + } + if self.holding_retention_secs == 0 { return Err(anyhow!( "NGIT_HOLDING_RETENTION_SECS must be greater than 0" @@ -1023,6 +1076,28 @@ impl Config { .collect() } + /// Parse the GRASP-08 service-wide member whitelist. + /// + /// Unlike repository/archive curation lists, malformed private members are + /// fatal: skipping an access-control entry would silently lock a user out. + pub fn parse_private_members(&self) -> Result> { + self.private_members + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + if !value.starts_with("npub1") { + return Err(anyhow!( + "Invalid NGIT_PRIVATE_MEMBERS entry '{value}': expected an npub" + )); + } + PublicKey::from_bech32(value).with_context(|| { + format!("Invalid npub in NGIT_PRIVATE_MEMBERS entry '{value}'") + }) + }) + .collect() + } + /// Get parsed archive configuration with computed read-only mode /// /// Read-only mode defaults to true if archive mode is enabled, false otherwise. @@ -1182,6 +1257,9 @@ impl Config { archive_grasp_services: String::new(), archive_read_only: None, grasp06_enable: false, + private_mode: false, + private_members: String::new(), + private_public_origin: String::new(), repository_whitelist: String::new(), repository_blacklist: String::new(), blacklist_auto_restore: false, diff --git a/src/git/mod.rs b/src/git/mod.rs index f912865..4424a5b 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -28,9 +28,11 @@ use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::process::Command; +use ::nostr::nips::nip19::FromBech32; use http_body_util::combinators::BoxBody; use http_body_util::{BodyExt, Full}; use hyper::body::Bytes; +use nostr_sdk::prelude::{PublicKey, ToBech32}; use tracing::{debug, info}; /// Unified HTTP response body for every route served by ngit-grasp. @@ -64,14 +66,13 @@ pub fn empty_body() -> GitResponseBody { /// * `identifier` - The repository identifier /// /// # Returns -/// Path to the bare Git repository -pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { - // Remove .git suffix if present - let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); - - PathBuf::from(git_data_path) - .join(npub) - .join(format!("{}.git", identifier)) +/// Path to the bare Git repository, or `None` when either URL component is +/// non-canonical or could escape the configured Git data root. +pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> Option { + validate_repository_coordinate(npub, identifier)?; + let root = PathBuf::from(git_data_path); + let candidate = root.join(npub).join(format!("{identifier}.git")); + candidate.starts_with(&root).then_some(candidate) } /// Check if a commit exists in the repository @@ -554,25 +555,68 @@ pub fn parse_git_url(path: &str) -> Option<(String, String, String)> { return None; } - let npub = parts[0].to_string(); + let npub = parts[0]; let repo_part = percent_decode(parts[1]); let subpath = parts[2].to_string(); - // Extract identifier (remove .git suffix if present for the middle part) - let identifier = repo_part - .strip_suffix(".git") - .unwrap_or(&repo_part) - .to_string(); + // `.git` is part of the HTTP route, not part of the NIP-34 identifier. + let identifier = repo_part.strip_suffix(".git")?; + validate_repository_coordinate(npub, identifier)?; - Some((npub, identifier, subpath)) + Some((npub.to_owned(), identifier.to_owned(), subpath)) +} + +/// Extract a validated repository coordinate from its canonical HTTP root. +pub fn parse_repository_root_url(path: &str) -> Option<(String, String)> { + let path = path.strip_prefix('/').unwrap_or(path); + let mut parts = path.split('/'); + let npub = parts.next()?; + let repo_part = percent_decode(parts.next()?); + if parts.next().is_some() { + return None; + } + let identifier = repo_part.strip_suffix(".git")?; + validate_repository_coordinate(npub, identifier)?; + Some((npub.to_owned(), identifier.to_owned())) +} + +fn validate_repository_coordinate(npub: &str, identifier: &str) -> Option<()> { + let public_key = PublicKey::from_bech32(npub).ok()?; + if public_key.to_bech32().ok()?.as_str() != npub { + return None; + } + if identifier.is_empty() + || identifier == "." + || identifier == ".." + || identifier.contains('/') + || identifier.contains('\\') + || identifier.contains('\0') + { + return None; + } + + let mut components = Path::new(identifier).components(); + match (components.next(), components.next()) { + (Some(std::path::Component::Normal(component)), None) + if component == std::ffi::OsStr::new(identifier) => + { + Some(()) + } + _ => None, + } } #[cfg(test)] mod tests { use super::*; + use nostr_sdk::prelude::Keys; use std::fs; use tempfile::TempDir; + fn test_npub() -> String { + Keys::generate().public_key().to_bech32().unwrap() + } + /// Create a test bare repository with optional commits fn create_test_repo() -> (TempDir, PathBuf) { let temp_dir = TempDir::new().unwrap(); @@ -670,28 +714,42 @@ mod tests { #[test] fn test_resolve_repo_path() { - let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo"); - assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); + let npub = test_npub(); + let path = resolve_repo_path("/data/git", &npub, "my-repo").unwrap(); + assert_eq!( + path, + PathBuf::from("/data/git").join(npub).join("my-repo.git") + ); } #[test] - fn test_resolve_repo_path_with_git_suffix() { - let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git"); - assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); + fn test_resolve_repo_path_preserves_identifier_git_suffix() { + let npub = test_npub(); + let path = resolve_repo_path("/data/git", &npub, "my-repo.git").unwrap(); + assert_eq!( + path, + PathBuf::from("/data/git") + .join(npub) + .join("my-repo.git.git") + ); } #[test] fn test_parse_git_url_info_refs() { - let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/info/refs").unwrap(); - assert_eq!(npub, "npub1abc"); + let npub = test_npub(); + let path = format!("/{npub}/repo.git/info/refs"); + let (parsed_npub, id, subpath) = parse_git_url(&path).unwrap(); + assert_eq!(parsed_npub, npub); assert_eq!(id, "repo"); assert_eq!(subpath, "info/refs"); } #[test] fn test_parse_git_url_upload_pack() { - let (npub, id, subpath) = parse_git_url("/npub1abc/repo.git/git-upload-pack").unwrap(); - assert_eq!(npub, "npub1abc"); + let npub = test_npub(); + let path = format!("/{npub}/repo.git/git-upload-pack"); + let (parsed_npub, id, subpath) = parse_git_url(&path).unwrap(); + assert_eq!(parsed_npub, npub); assert_eq!(id, "repo"); assert_eq!(subpath, "git-upload-pack"); } @@ -700,19 +758,41 @@ mod tests { fn test_parse_git_url_invalid() { assert!(parse_git_url("/npub1abc").is_none()); assert!(parse_git_url("/npub1abc/repo").is_none()); + assert!(parse_git_url("/npub1abc/repo.git/info/refs").is_none()); } #[test] fn test_parse_git_url_percent_encoded_identifier() { // Identifiers with spaces encoded as %20 must be decoded so the // filesystem path lookup finds the correct directory. - let (npub, id, subpath) = - parse_git_url("/npub17plqk/kuboslopp%20by%20Shakespeare.git/info/refs").unwrap(); - assert_eq!(npub, "npub17plqk"); + let npub = test_npub(); + let path = format!("/{npub}/kuboslopp%20by%20Shakespeare.git/info/refs"); + let (parsed_npub, id, subpath) = parse_git_url(&path).unwrap(); + assert_eq!(parsed_npub, npub); assert_eq!(id, "kuboslopp by Shakespeare"); assert_eq!(subpath, "info/refs"); } + #[test] + fn repository_urls_reject_encoded_traversal_and_noncanonical_npubs() { + let npub = test_npub(); + for identifier in [ + "..", + "%2E%2E", + "%2E%2E%2Fother", + "nested%2Frepo", + r"nested%5Crepo", + ] { + let smart = format!("/{npub}/{identifier}.git/info/refs"); + let root = format!("/{npub}/{identifier}.git"); + assert!(parse_git_url(&smart).is_none(), "{smart}"); + assert!(parse_repository_root_url(&root).is_none(), "{root}"); + } + assert!(parse_git_url("/../repo.git/info/refs").is_none()); + assert!(resolve_repo_path("/data/git", "..", "repo").is_none()); + assert!(resolve_repo_path("/data/git", &npub, "../repo").is_none()); + } + #[test] fn test_percent_decode_basic() { assert_eq!(percent_decode("hello%20world"), "hello world"); diff --git a/src/http/mod.rs b/src/http/mod.rs index b75568c..a5e979a 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -33,6 +33,7 @@ use crate::metrics::Metrics; use crate::nostr::builder::Nip34WritePolicy; use crate::nostr::lifecycle::RepositoryLifecycle; use crate::nostr::SharedDatabase; +use crate::private::{nip98, PrivateAccess}; use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks; use crate::purgatory::Purgatory; use crate::sync::rejected_index::RejectedEventsIndex; @@ -42,7 +43,8 @@ type HttpBody = GitResponseBody; /// CORS headers required by GRASP-01 specification (lines 48-51) const CORS_ALLOW_ORIGIN: &str = "*"; const CORS_ALLOW_METHODS: &str = "GET, POST"; -const CORS_ALLOW_HEADERS: &str = "Content-Type"; +const CORS_ALLOW_HEADERS: &str = "Content-Type, Authorization, Git-Protocol"; +const CORS_EXPOSE_HEADERS: &str = "WWW-Authenticate"; /// Embedded icon image (Grasp logo) const ICON_PNG: &[u8] = include_bytes!("../../static/icon.png"); @@ -65,43 +67,7 @@ fn parse_repo_url(path: &str) -> Option<(String, String)> { if path.starts_with("/prs/") || path.starts_with("prs/") { return None; } - - // Remove leading slash - let path = path.strip_prefix('/').unwrap_or(path); - - // Split into components - let parts: Vec<&str> = path.split('/').collect(); - - // Must be exactly 2 parts: npub and repo.git (no subpath) - if parts.len() != 2 { - return None; - } - - let npub = parts[0]; - let repo_part = git::percent_decode(parts[1]); - - // The repo part must end with .git - if !repo_part.ends_with(".git") { - return None; - } - - // Must have an npub that looks valid (starts with npub1) - if !npub.starts_with("npub1") { - return None; - } - - // Extract identifier (remove .git suffix) - let identifier = repo_part - .strip_suffix(".git") - .unwrap_or(&repo_part) - .to_string(); - - // Identifier must not be empty - if identifier.is_empty() { - return None; - } - - Some((npub.to_string(), identifier)) + git::parse_repository_root_url(path) } /// Add CORS headers to a response builder @@ -110,6 +76,7 @@ fn add_cors_headers(builder: hyper::http::response::Builder) -> hyper::http::res .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN) .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS) .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS) + .header("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS) } /// HTTP Service that serves both WebSocket (relay) and HTML landing page @@ -132,6 +99,8 @@ struct HttpService { /// Per-path init mutexes for GRASP-06 `/prs/` on-demand bare-repo /// creation. See [`crate::grasp06::receive::RepoInitLocks`]. repo_init_locks: RepoInitLocks, + /// GRASP-08 access list. Present only when private mode is enabled. + private_access: Option, } impl HttpService { @@ -147,6 +116,7 @@ impl HttpService { lifecycle: Arc, rejected_events_index: Arc, repo_init_locks: RepoInitLocks, + private_access: Option, ) -> Self { Self { relay, @@ -159,6 +129,7 @@ impl HttpService { lifecycle, rejected_events_index, repo_init_locks, + private_access, } } } @@ -431,6 +402,24 @@ impl Service> for HttpService { // Check for Git HTTP requests first if let Some((npub, identifier, subpath)) = git::parse_git_url(&path) { + if let Some(access) = &self.private_access { + let authorized = nip98::canonical_repository_url(&self.config, &path) + .is_some_and(|url| nip98::validate_request(&req, &url, access).is_ok()); + if !authorized { + let response = nip98::unauthorized_response(&self.config); + return Box::pin(async move { + let (parts, body) = response.into_parts(); + Ok(add_cors_headers(Response::builder().status(parts.status)) + .header( + "www-authenticate", + parts.headers["www-authenticate"].clone(), + ) + .body(body) + .unwrap()) + }); + } + } + // Extract Git-Protocol header for protocol v2 support let git_protocol = req .headers() @@ -457,7 +446,8 @@ impl Service> for HttpService { content_encoding ); - let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier); + let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier) + .expect("parse_git_url validated repository coordinate"); let metrics_clone = self.metrics.clone(); let relay = self.relay.clone(); @@ -709,8 +699,27 @@ impl Service> for HttpService { // GRASP-01: "SHOULD serve a webpage at the same endpoint linking to git nostr client(s) // to browse the repository and a 404 page for repositories it doesn't host" if let Some((npub, identifier)) = parse_repo_url(&path) { + if let Some(access) = &self.private_access { + let authorized = nip98::canonical_repository_url(&self.config, &path) + .is_some_and(|url| nip98::validate_request(&req, &url, access).is_ok()); + if !authorized { + let response = nip98::unauthorized_response(&self.config); + return Box::pin(async move { + let (parts, body) = response.into_parts(); + Ok(add_cors_headers(Response::builder().status(parts.status)) + .header( + "www-authenticate", + parts.headers["www-authenticate"].clone(), + ) + .body(body) + .unwrap()) + }); + } + } + let config = self.config.clone(); - let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier); + let repo_path = git::resolve_repo_path(&git_data_path, &npub, &identifier) + .expect("parse_repo_url validated repository coordinate"); tracing::debug!( "Repository URL request: {} (npub={}, id={}, path={:?})", @@ -769,6 +778,8 @@ impl Service> for HttpService { ); let relay = self.relay.clone(); let metrics_clone = self.metrics.clone(); + let private_access = self.private_access.clone(); + let relay_domain = self.config.domain.clone(); tokio::spawn(async move { match hyper::upgrade::on(req).await { @@ -785,7 +796,23 @@ impl Service> for HttpService { m.connection_tracker().on_connect(addr.ip()); m.record_websocket_connection(); } - if let Err(e) = + if let Some(access) = private_access { + if let Err(e) = crate::private::ws_auth::authenticate_and_bridge( + TokioIo::new(upgraded), + addr, + relay, + &relay_domain, + access, + ) + .await + { + tracing::debug!( + "Private relay connection ended for {}: {}", + addr, + e + ); + } + } else if let Err(e) = relay.take_connection(TokioIo::new(upgraded), addr).await { tracing::error!( @@ -918,6 +945,7 @@ pub async fn run_server( lifecycle: Arc, rejected_events_index: Arc, repo_init_locks: RepoInitLocks, + private_access: Option, ) -> anyhow::Result<()> { let bind_addr: SocketAddr = config.bind_address.parse()?; let listener = TcpListener::bind(&bind_addr).await?; @@ -932,6 +960,7 @@ pub async fn run_server( lifecycle, rejected_events_index, repo_init_locks, + private_access, ) .await } @@ -960,6 +989,7 @@ pub async fn run_server_on_listener( lifecycle: Arc, rejected_events_index: Arc, repo_init_locks: RepoInitLocks, + private_access: Option, ) -> anyhow::Result<()> { tracing::info!("Starting HTTP server on {}", listener.local_addr()?); tracing::info!("Relay name: {}", config.relay_name()); @@ -985,6 +1015,7 @@ pub async fn run_server_on_listener( lifecycle.clone(), rejected_events_index.clone(), repo_init_locks.clone(), + private_access.clone(), ); tokio::spawn(async move { diff --git a/src/http/nip11.rs b/src/http/nip11.rs index 531951c..4a365e8 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -95,6 +95,9 @@ impl RelayInformationDocument { if config.grasp06_enable { supported_grasps.push("GRASP-06".to_string()); } + if config.private_mode { + supported_grasps.push("GRASP-08".to_string()); + } // Build curation field for archive read-only mode or repository whitelist let repository_config = config.repository_config(); @@ -150,6 +153,10 @@ impl RelayInformationDocument { nips.push(9); // NIP-09: Event deletion requests nips.push(62); // NIP-62: Request to vanish } + if config.private_mode { + nips.push(42); // NIP-42: relay client authentication + nips.push(98); // NIP-98: HTTP authentication + } nips.sort_unstable(); nips }, diff --git a/src/lib.rs b/src/lib.rs index ce5620d..69a927b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod logging; pub mod metrics; pub mod nostr; pub mod outbound; +pub mod private; pub mod purgatory; pub mod server; pub mod sync; diff --git a/src/private/access.rs b/src/private/access.rs new file mode 100644 index 0000000..89a0ef5 --- /dev/null +++ b/src/private/access.rs @@ -0,0 +1,89 @@ +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +use nostr_sdk::prelude::PublicKey; +use tokio::sync::watch; + +/// Live service-wide access list for a private GRASP instance. +/// +/// The generation channel lets authenticated WebSocket sessions fail closed +/// when a later fleet/control-plane implementation changes membership. +#[derive(Clone, Debug)] +pub struct PrivateAccess { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + members: RwLock>, + generation: watch::Sender, +} + +impl PrivateAccess { + pub fn new(members: impl IntoIterator) -> Self { + let (generation, _) = watch::channel(0); + Self { + inner: Arc::new(Inner { + members: RwLock::new(members.into_iter().collect()), + generation, + }), + } + } + + pub fn contains(&self, pubkey: &PublicKey) -> bool { + self.inner + .members + .read() + .expect("private access lock poisoned") + .contains(pubkey) + } + + pub fn len(&self) -> usize { + self.inner + .members + .read() + .expect("private access lock poisoned") + .len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Atomically replace membership and notify active sessions. + pub fn replace(&self, members: impl IntoIterator) { + *self + .inner + .members + .write() + .expect("private access lock poisoned") = members.into_iter().collect(); + let next = self.inner.generation.borrow().wrapping_add(1); + self.inner.generation.send_replace(next); + } + + pub fn subscribe(&self) -> watch::Receiver { + self.inner.generation.subscribe() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::prelude::Keys; + + #[test] + fn replacement_updates_membership() { + let first = Keys::generate().public_key(); + let second = Keys::generate().public_key(); + let access = PrivateAccess::new([first]); + let generation = access.subscribe(); + + assert!(access.contains(&first)); + assert!(!access.contains(&second)); + + access.replace([second]); + assert!(!access.contains(&first)); + assert!(access.contains(&second)); + assert_eq!(*generation.borrow(), 1); + } +} diff --git a/src/private/mod.rs b/src/private/mod.rs new file mode 100644 index 0000000..1a5596f --- /dev/null +++ b/src/private/mod.rs @@ -0,0 +1,10 @@ +//! GRASP-08 private-service authentication. +//! +//! Private mode adds a service-wide member list used by both the Nostr +//! WebSocket relay (NIP-42) and Git Smart HTTP (the GRASP-08 NIP-98 profile). + +pub mod access; +pub mod nip98; +pub mod ws_auth; + +pub use access::PrivateAccess; diff --git a/src/private/nip98.rs b/src/private/nip98.rs new file mode 100644 index 0000000..b61d36d --- /dev/null +++ b/src/private/nip98.rs @@ -0,0 +1,248 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use hyper::header::{AUTHORIZATION, WWW_AUTHENTICATE}; +use hyper::{Request, Response, StatusCode}; +use nostr_sdk::prelude::{Event, Kind}; + +use crate::config::Config; +use crate::git::{empty_body, GitResponseBody}; +use crate::private::PrivateAccess; + +const MAX_CLOCK_SKEW_SECS: u64 = 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Nip98Error { + Missing, + Malformed, + Invalid, + Restricted, +} + +/// Validate the GRASP-08 repository-scoped NIP-98 credential. +/// +/// This deliberately differs from generic NIP-98: the signed method is always +/// GET, the signed URL is the repository root, payload is ignored, and replay +/// is allowed during the 60-second validity window. +pub fn validate_request( + request: &Request, + canonical_repository_url: &str, + access: &PrivateAccess, +) -> Result<(), Nip98Error> { + let header = request + .headers() + .get(AUTHORIZATION) + .ok_or(Nip98Error::Missing)? + .to_str() + .map_err(|_| Nip98Error::Malformed)?; + let encoded = header + .strip_prefix("Nostr ") + .filter(|value| !value.is_empty()) + .ok_or(Nip98Error::Malformed)?; + let json = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| Nip98Error::Malformed)?; + let event = Event::from_json(json).map_err(|_| Nip98Error::Malformed)?; + + if event.kind != Kind::HttpAuth || event.verify().is_err() { + return Err(Nip98Error::Invalid); + } + if !access.contains(&event.pubkey) { + return Err(Nip98Error::Restricted); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Nip98Error::Invalid)? + .as_secs(); + if now.abs_diff(event.created_at.as_secs()) > MAX_CLOCK_SKEW_SECS { + return Err(Nip98Error::Invalid); + } + + let mut urls = event.tags.iter().filter_map(|tag| { + let values = tag.clone().to_vec(); + (values.len() == 2 && values[0] == "u").then(|| values[1].clone()) + }); + if urls.next().as_deref() != Some(canonical_repository_url) || urls.next().is_some() { + return Err(Nip98Error::Invalid); + } + + let mut methods = event.tags.iter().filter_map(|tag| { + let values = tag.clone().to_vec(); + (values.len() == 2 && values[0] == "method").then(|| values[1].clone()) + }); + if methods.next().as_deref() != Some("GET") || methods.next().is_some() { + return Err(Nip98Error::Invalid); + } + + Ok(()) +} + +/// Build the canonical absolute root URL signed by Git credentials. +pub fn canonical_repository_url(config: &Config, request_path: &str) -> Option { + // Identifiers may themselves contain `.git`; the route suffix is the last + // occurrence before the Smart HTTP subpath. + let end = request_path.rfind(".git")?.checked_add(4)?; + let repository_path = request_path.get(..end)?; + if !config.private_public_origin.trim().is_empty() { + return Some(format!( + "{}{repository_path}", + config.private_public_origin.trim().trim_end_matches('/') + )); + } + let scheme = if is_loopback_domain(&config.domain) { + "http" + } else { + "https" + }; + Some(format!("{scheme}://{}{repository_path}", config.domain)) +} + +/// Return the intentionally indistinguishable response for every auth failure. +pub fn unauthorized_response(config: &Config) -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + WWW_AUTHENTICATE, + format!("Nostr realm=\"{}\", method=\"GET\"", config.domain), + ) + .body(empty_body()) + .expect("static unauthorized response") +} + +fn is_loopback_domain(domain: &str) -> bool { + let host = domain + .strip_prefix('[') + .and_then(|value| value.split_once(']').map(|(host, _)| host)) + .unwrap_or_else(|| domain.split(':').next().unwrap_or(domain)); + host.eq_ignore_ascii_case("localhost") + || host == "::1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +mod tests { + use base64::engine::general_purpose::STANDARD; + use hyper::Request; + use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Tag, Timestamp}; + + use super::*; + + fn credential( + keys: &Keys, + url: &str, + method: &str, + created_at: Timestamp, + extra_tags: Vec, + ) -> String { + let mut tags = vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + ]; + tags.extend(extra_tags); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .custom_created_at(created_at) + .finalize(keys) + .unwrap(); + format!("Nostr {}", STANDARD.encode(event.as_json())) + } + + #[test] + fn accepts_reusable_get_credential_and_ignores_payload() { + let keys = Keys::generate(); + let access = PrivateAccess::new([keys.public_key()]); + let url = "https://private.example/npub/repo.git"; + let auth = credential( + &keys, + url, + "GET", + Timestamp::now(), + vec![Tag::parse(["payload", "not-validated"]).unwrap()], + ); + let request = Request::post("/npub/repo.git/git-receive-pack") + .header(AUTHORIZATION, auth) + .body(()) + .unwrap(); + + assert_eq!(validate_request(&request, url, &access), Ok(())); + assert_eq!(validate_request(&request, url, &access), Ok(())); + } + + #[test] + fn rejects_wrong_method_url_member_and_duplicate_required_tags() { + let member = Keys::generate(); + let outsider = Keys::generate(); + let access = PrivateAccess::new([member.public_key()]); + let url = "https://private.example/npub/repo.git"; + + for auth in [ + credential(&member, url, "POST", Timestamp::now(), vec![]), + credential( + &member, + "https://private.example/npub/other.git", + "GET", + Timestamp::now(), + vec![], + ), + credential(&outsider, url, "GET", Timestamp::now(), vec![]), + credential( + &member, + url, + "GET", + Timestamp::now(), + vec![Tag::parse(["u", url]).unwrap()], + ), + ] { + let request = Request::get("/") + .header(AUTHORIZATION, auth) + .body(()) + .unwrap(); + assert!(validate_request(&request, url, &access).is_err()); + } + } + + #[test] + fn canonical_url_uses_http_only_for_loopback() { + let mut config = Config::for_testing(); + config.domain = "127.0.0.1:7334".into(); + assert_eq!( + canonical_repository_url(&config, "/npub/repo.git/info/refs").as_deref(), + Some("http://127.0.0.1:7334/npub/repo.git") + ); + config.domain = "private.example".into(); + assert_eq!( + canonical_repository_url(&config, "/npub/repo.git/git-upload-pack").as_deref(), + Some("https://private.example/npub/repo.git") + ); + config.private_public_origin = "http://10.0.0.5:8080".into(); + assert_eq!( + canonical_repository_url(&config, "/npub/repo.git/info/refs?service=x").as_deref(), + Some("http://10.0.0.5:8080/npub/repo.git") + ); + } + + #[test] + fn canonical_url_preserves_git_inside_identifier() { + let mut config = Config::for_testing(); + config.private_public_origin = "https://private.example".into(); + assert_eq!( + canonical_repository_url(&config, "/npub/repository.git-tools.git/info/refs") + .as_deref(), + Some("https://private.example/npub/repository.git-tools.git") + ); + } + + #[test] + fn every_failure_response_is_empty_401_with_challenge() { + let config = Config::for_testing(); + let response = unauthorized_response(&config); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers()[WWW_AUTHENTICATE], + "Nostr realm=\"localhost:7334\", method=\"GET\"" + ); + } +} diff --git a/src/private/ws_auth.rs b/src/private/ws_auth.rs new file mode 100644 index 0000000..ef404de --- /dev/null +++ b/src/private/ws_auth.rs @@ -0,0 +1,279 @@ +use std::borrow::Cow; +use std::net::SocketAddr; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use nostr_sdk::local_relay::LocalRelay; +use nostr_sdk::prelude::{ + nip42, ClientMessage, Kind, PublicKey, RelayMessage, RelayUrl, SubscriptionId, Timestamp, +}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::WebSocketStream; + +use crate::private::PrivateAccess; + +const AUTH_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_AUTH_ATTEMPTS: usize = 3; +const DUPLEX_BUFFER_BYTES: usize = 64 * 1024; + +/// Authenticate a public upgraded WebSocket before attaching it to LocalRelay. +pub async fn authenticate_and_bridge( + stream: S, + addr: SocketAddr, + relay: LocalRelay, + relay_domain: &str, + access: PrivateAccess, +) -> Result<(), String> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let mut public = WebSocketStream::from_raw_socket(stream, Role::Server, None).await; + let challenge = SubscriptionId::generate().to_string(); + send_relay_message( + &mut public, + RelayMessage::Auth { + challenge: Cow::Borrowed(&challenge), + }, + ) + .await?; + + let authenticated = tokio::time::timeout( + AUTH_TIMEOUT, + authenticate(&mut public, relay_domain, &challenge, &access), + ) + .await + .map_err(|_| "NIP-42 authentication timed out".to_string())??; + + bridge(public, addr, relay, authenticated, access).await +} + +async fn authenticate( + public: &mut WebSocketStream, + relay_domain: &str, + challenge: &str, + access: &PrivateAccess, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let relay_urls = relay_url_candidates(relay_domain)?; + let mut attempts = 0; + + while let Some(message) = public.next().await { + let message = message.map_err(|e| e.to_string())?; + match message { + Message::Text(json) => { + let parsed = ClientMessage::from_json(json.as_bytes()); + match parsed { + Ok(ClientMessage::Auth(event)) => { + attempts += 1; + let valid = event.kind == Kind::Authentication + && event.verify().is_ok() + && Timestamp::now() + .as_secs() + .abs_diff(event.created_at.as_secs()) + <= 120 + && relay_urls + .iter() + .any(|url| nip42::is_valid_auth_event(&event, url, challenge)); + + if valid && access.contains(&event.pubkey) { + send_relay_message( + public, + RelayMessage::Ok { + event_id: event.id, + status: true, + message: Cow::Borrowed(""), + }, + ) + .await?; + return Ok(event.pubkey); + } + + let message = if valid { + "restricted: authenticated pubkey is not whitelisted" + } else { + "auth-required: invalid NIP-42 authentication" + }; + send_relay_message( + public, + RelayMessage::Ok { + event_id: event.id, + status: false, + message: Cow::Borrowed(message), + }, + ) + .await?; + if valid || attempts >= MAX_AUTH_ATTEMPTS { + let _ = public.close(None).await; + return Err(message.to_string()); + } + } + Ok(other) => send_auth_required(public, &other).await?, + Err(_) => { + send_relay_message( + public, + RelayMessage::Notice(Cow::Borrowed( + "auth-required: authenticate before sending relay messages", + )), + ) + .await?; + } + } + } + Message::Ping(payload) => public + .send(Message::Pong(payload)) + .await + .map_err(|e| e.to_string())?, + Message::Close(_) => return Err("connection closed before authentication".into()), + Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {} + } + } + + Err("connection closed before authentication".into()) +} + +async fn send_auth_required( + public: &mut WebSocketStream, + message: &ClientMessage<'_>, +) -> Result<(), String> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + match message { + ClientMessage::Event(event) => { + send_relay_message( + public, + RelayMessage::Ok { + event_id: event.id, + status: false, + message: Cow::Borrowed("auth-required: authenticate before publishing"), + }, + ) + .await + } + ClientMessage::Req { + subscription_id, .. + } + | ClientMessage::Count { + subscription_id, .. + } => { + send_relay_message( + public, + RelayMessage::Closed { + subscription_id: Cow::Owned(subscription_id.clone().into_owned()), + message: Cow::Borrowed("auth-required: authenticate before reading"), + }, + ) + .await + } + ClientMessage::NegOpen { + subscription_id, .. + } => { + send_relay_message( + public, + RelayMessage::NegErr { + subscription_id: Cow::Owned(subscription_id.clone().into_owned()), + message: Cow::Borrowed("auth-required: authenticate before syncing"), + }, + ) + .await + } + _ => { + send_relay_message( + public, + RelayMessage::Notice(Cow::Borrowed( + "auth-required: authenticate before sending relay messages", + )), + ) + .await + } + } +} + +async fn bridge( + mut public: WebSocketStream, + addr: SocketAddr, + relay: LocalRelay, + authenticated: PublicKey, + access: PrivateAccess, +) -> Result<(), String> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (proxy_stream, relay_stream) = tokio::io::duplex(DUPLEX_BUFFER_BYTES); + let relay_task = tokio::spawn(async move { relay.take_connection(relay_stream, addr).await }); + let mut internal = WebSocketStream::from_raw_socket(proxy_stream, Role::Client, None).await; + let mut membership = access.subscribe(); + + loop { + tokio::select! { + changed = membership.changed() => { + if changed.is_err() || !access.contains(&authenticated) { + let _ = public.close(None).await; + let _ = internal.close(None).await; + break; + } + } + message = public.next() => { + match message { + Some(Ok(message)) if access.contains(&authenticated) => { + internal.send(message).await.map_err(|e| e.to_string())?; + } + Some(Ok(_)) => break, + Some(Err(e)) => return Err(e.to_string()), + None => break, + } + } + message = internal.next() => { + match message { + Some(Ok(message)) if access.contains(&authenticated) => { + public.send(message).await.map_err(|e| e.to_string())?; + } + Some(Ok(_)) => break, + Some(Err(e)) => return Err(e.to_string()), + None => break, + } + } + } + } + + relay_task.abort(); + Ok(()) +} + +async fn send_relay_message( + socket: &mut WebSocketStream, + message: RelayMessage<'_>, +) -> Result<(), String> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + socket + .send(Message::Text(message.as_json().into())) + .await + .map_err(|e| e.to_string()) +} + +fn relay_url_candidates(domain: &str) -> Result<[RelayUrl; 2], String> { + let secure = format!("wss://{domain}"); + let insecure = format!("ws://{domain}"); + Ok([ + RelayUrl::parse(&secure).map_err(|e| e.to_string())?, + RelayUrl::parse(&insecure).map_err(|e| e.to_string())?, + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_secure_and_loopback_relay_candidates() { + let urls = relay_url_candidates("private.example").unwrap(); + assert_eq!(urls[0].as_str(), "wss://private.example"); + assert_eq!(urls[1].as_str(), "ws://private.example"); + } +} diff --git a/src/server.rs b/src/server.rs index 4119ace..1cd8382 100644 --- a/src/server.rs +++ b/src/server.rs @@ -30,6 +30,7 @@ use crate::{ metrics::Metrics, nostr::{self, builder::Nip34WritePolicy, lifecycle::RepositoryLifecycle, SharedDatabase}, outbound::OutboundTargetPolicy, + private::PrivateAccess, purgatory::{sync::RealSyncContext, sync::ThrottleManager, Purgatory}, sync::{naughty_list::NaughtyListTracker, rejected_index::RejectedEventsIndex, SyncManager}, }; @@ -56,6 +57,7 @@ pub struct RelayServer { lifecycle: Arc, rejected_events_index: Arc, repo_init_locks: crate::grasp06::receive::RepoInitLocks, + private_access: Option, deletion_cleanup: nostr::lifecycle::DeletionCleanupTask, /// Detached background loops (sync manager, purgatory cleanup, audit /// cleanup, purgatory sync loop). Aborted on shutdown so the host @@ -97,6 +99,16 @@ impl RelayServer { // Recoverable issues (e.g., malformed whitelist entries) are logged // as warnings. config.validate()?; + let private_access = if config.private_mode { + let access = PrivateAccess::new(config.parse_private_members()?); + info!( + "GRASP-08 private mode enabled for {} configured member(s)", + access.len() + ); + Some(access) + } else { + None + }; info!( "Configuration loaded and validated: {}", @@ -375,6 +387,7 @@ impl RelayServer { purgatory, rejected_events_index, repo_init_locks, + private_access, deletion_cleanup, background_tasks, git_data_path, @@ -414,6 +427,7 @@ impl RelayServer { self.lifecycle, self.rejected_events_index.clone(), self.repo_init_locks, + self.private_access, ); let result = tokio::select! {