From d7766dace794247eb04bc78eaccc08dc027965d0 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 13 Aug 2026 21:24:42 +0000 Subject: [PATCH 1/4] feat(private-repos): require GRASP-08 service authentication Private repository events and Git objects must not be readable merely because an endpoint is reachable. Add an opt-in, fail-closed single-service mode that requires NIP-42 authentication before bridging WebSocket traffic and a repository-scoped GRASP-08 NIP-98 credential before serving Smart HTTP. The Git credential signs the canonical repository root with method GET and is reusable across the standard Git endpoints for its 60-second validity window. Authentication precedes repository lookup, every failure returns the same empty 401 challenge, canonical paths cannot escape the Git root, browser clients can inspect the challenge through CORS, and ordinary GRASP-01 push authorization remains authoritative after authentication. The canonical public origin is operator-controlled so reverse proxies cannot influence signed identity. This commit deliberately implements one configured private service and excludes dynamic relay-owner membership, encrypted kind-10318 client discovery, and multi-service fleet orchestration. Validation before consolidation: cargo check --all-targets passed on current master. Focused unit coverage exercises credential reuse, signature/member/URL/method rejection, canonical origin and repository paths, fail-closed configuration, authentication framing, and empty indistinguishable failures. --- .env.example | 24 +++ Cargo.toml | 1 + docs/explanation/architecture.md | 28 ++++ docs/reference/configuration.md | 47 ++++++ nix/module.nix | 32 ++++ src/config.rs | 78 +++++++++ src/git/mod.rs | 134 ++++++++++++--- src/http/mod.rs | 113 ++++++++----- src/http/nip11.rs | 7 + src/lib.rs | 1 + src/private/access.rs | 89 ++++++++++ src/private/mod.rs | 10 ++ src/private/nip98.rs | 248 +++++++++++++++++++++++++++ src/private/ws_auth.rs | 279 +++++++++++++++++++++++++++++++ src/server.rs | 14 ++ 15 files changed, 1037 insertions(+), 68 deletions(-) create mode 100644 src/private/access.rs create mode 100644 src/private/mod.rs create mode 100644 src/private/nip98.rs create mode 100644 src/private/ws_auth.rs 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! { From 6b4d585bf0817597c76e94e7f3c0fe98be2e14bc Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 13 Aug 2026 21:26:36 +0000 Subject: [PATCH 2/4] feat(private-repos): admit accepted relay owners GRASP-08 requires the service whitelist to include NIP-11 owners of relays referenced by accepted announcements. Static configuration alone would incorrectly deny those relay operators and make repository synchronization between private services unusable. Carry the owner field through the existing once-per-session NIP-11 limit fetch, retain the latest owner per canonical relay, and reconcile the shared inbound access set from configured members plus owners whose relays appear in Full repository sync entries. StateOnly purgatory entries are excluded because unaccepted peer input must not grant access. Reconciliation runs on connection completion and the existing five-second maintenance cadence; unchanged sets do not churn authenticated sessions. This adds no connection, request, subscription, or configuration option. Multi-service fleet orchestration and encrypted kind-10318 discovery remain separate future work. Validation: cargo check --all-targets passed; focused private authentication tests passed (7/7); unit coverage verifies owner parsing without a NIP-11 limitation object, configured/accepted/unrelated membership selection, and no generation change for an identical replacement. Full suite validation follows on the completed two-commit stack. --- .env.example | 6 +- docs/explanation/architecture.md | 11 ++++ docs/reference/configuration.md | 8 ++- nix/module.nix | 8 ++- src/config.rs | 6 +- src/private/access.rs | 18 ++++-- src/server.rs | 1 + src/sync/mod.rs | 94 ++++++++++++++++++++++++++++++++ src/sync/relay_connection.rs | 52 ++++++++++++------ 9 files changed, 175 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index cba21cc..81380df 100644 --- a/.env.example +++ b/.env.example @@ -311,8 +311,10 @@ # Default: false # NGIT_PRIVATE_MODE=false -# Service-wide member whitelist as comma-separated npubs. Every entry is -# validated at startup; malformed entries fail closed. +# Permanently configured service-wide members as comma-separated npubs. Every +# entry is validated at startup; malformed entries fail closed. The effective +# GRASP-08 whitelist also includes NIP-11 owners of relays referenced by +# accepted repository announcements. # # CLI: --private-members # Default: (empty; at least one member is required in private mode) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 7f0db17..defcaaa 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -689,6 +689,11 @@ 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. +The effective set combines operator-configured members with NIP-11 owner +pubkeys learned for relays referenced by accepted announcements. Purgatory-only +announcements are excluded. Reconciliation reuses the accepted repository index +and the NIP-11 fetch already performed once per connection session, so private +mode adds neither outbound connections nor subscriptions. For Nostr, Hyper completes the public WebSocket upgrade and a message-level proxy sends and validates NIP-42 authentication before a connection reaches @@ -711,6 +716,12 @@ 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. +One process currently represents one private collaborator service. Operators +can run several independently configured instances for different groups. Fleet +provisioning and lifecycle automation are deliberately left to a later change; +they are deployment conveniences rather than part of the authentication +boundary implemented here. + ## Future Extensions ### GRASP-02: Proactive Sync diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2c14a85..ce192b6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1021,7 +1021,7 @@ GRASP-06 contributor endpoint is intentionally unauthenticated. #### `NGIT_PRIVATE_MEMBERS` -**Description:** Service-wide private-service member whitelist +**Description:** Permanently configured members of the service-wide private-service whitelist **Type:** Comma-separated npubs **Default:** Empty **Required:** Yes when `NGIT_PRIVATE_MODE=true` @@ -1030,6 +1030,12 @@ GRASP-06 contributor endpoint is intentionally unauthenticated. NGIT_PRIVATE_MEMBERS=npub1alice...,npub1bob... ``` +The effective GRASP-08 whitelist is the union of these configured members and +the NIP-11 owner pubkeys of relays referenced by accepted repository +announcements. Relay owners are learned by the NIP-11 request already made for +sync limits; this does not open another connection or subscription. A relay +listed only by an unpromoted purgatory announcement cannot grant access. + #### `NGIT_PRIVATE_PUBLIC_ORIGIN` Optional canonical external HTTP origin for private Git authentication, such diff --git a/nix/module.nix b/nix/module.nix index 7bbd3ba..ed4712c 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -406,9 +406,11 @@ let 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. + Permanently configured GRASP-08 service members. The effective + whitelist also includes NIP-11 owners of relays referenced by + accepted repository announcements. At least one valid configured + npub is required when privateMode is enabled; malformed entries make + the service fail at startup. ''; }; diff --git a/src/config.rs b/src/config.rs index 15d2337..358c341 100644 --- a/src/config.rs +++ b/src/config.rs @@ -599,9 +599,11 @@ pub struct Config { #[arg(long, env = "NGIT_PRIVATE_MODE", default_value_t = false)] pub private_mode: bool, - /// Service-wide GRASP-08 member whitelist as comma-separated npubs. + /// Permanently configured GRASP-08 members as comma-separated npubs. /// - /// Required and fail-closed when private mode is enabled. + /// The effective whitelist also includes NIP-11 owners of relays referenced + /// by accepted repository announcements. Required and fail-closed when + /// private mode is enabled. #[arg(long, env = "NGIT_PRIVATE_MEMBERS", default_value = "")] pub private_members: String, diff --git a/src/private/access.rs b/src/private/access.rs index 89a0ef5..7b9379d 100644 --- a/src/private/access.rs +++ b/src/private/access.rs @@ -51,14 +51,21 @@ impl PrivateAccess { } /// Atomically replace membership and notify active sessions. - pub fn replace(&self, members: impl IntoIterator) { - *self + pub fn replace(&self, members: impl IntoIterator) -> bool { + let members = members.into_iter().collect(); + let mut current = self .inner .members .write() - .expect("private access lock poisoned") = members.into_iter().collect(); + .expect("private access lock poisoned"); + if *current == members { + return false; + } + *current = members; + drop(current); let next = self.inner.generation.borrow().wrapping_add(1); self.inner.generation.send_replace(next); + true } pub fn subscribe(&self) -> watch::Receiver { @@ -81,9 +88,12 @@ mod tests { assert!(access.contains(&first)); assert!(!access.contains(&second)); - access.replace([second]); + assert!(access.replace([second])); assert!(!access.contains(&first)); assert!(access.contains(&second)); assert_eq!(*generation.borrow(), 1); + + assert!(!access.replace([second])); + assert_eq!(*generation.borrow(), 1); } } diff --git a/src/server.rs b/src/server.rs index 1cd8382..d20cb98 100644 --- a/src/server.rs +++ b/src/server.rs @@ -233,6 +233,7 @@ impl RelayServer { &config, PathBuf::from(config.effective_git_data_path()), metrics.as_ref().and_then(|m| m.sync_metrics().cloned()), + private_access.clone(), ); if config.sync_bootstrap_relay_url.is_some() { diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 8033703..761dafd 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -57,6 +57,7 @@ use crate::nostr::SharedDatabase; use crate::outbound::{ url_matches_service_domain, OutboundTargetKind, OutboundTargetPolicy, RelayTargetSource, }; +use crate::private::PrivateAccess; use nostr_sdk::prelude::LocalRelay; const MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK: usize = 32; @@ -207,6 +208,22 @@ fn dependency_relay_retention() -> Duration { } } +fn effective_private_members( + configured: &HashSet, + accepted_relays: &HashSet, + relay_owners: &HashMap, +) -> HashSet { + configured + .iter() + .copied() + .chain( + relay_owners + .iter() + .filter_map(|(relay, owner)| accepted_relays.contains(relay).then_some(*owner)), + ) + .collect() +} + fn byte_limited_catchup_interval() -> Duration { if std::env::var("NGIT_TEST").as_deref() == Ok("1") { Duration::from_secs(2) @@ -1767,6 +1784,7 @@ enum ConnectAttemptOutcome { Connected { advertised_default_limit: Option, advertised_max_subscriptions: Option, + advertised_owner: Option, }, Failed(String), } @@ -2201,6 +2219,7 @@ async fn run_purgatory_announcement_sync( _ = tokio::time::sleep(interval) => { let mut manager = sync_manager.lock().await; manager.sync_purgatory_announcements_to_index().await; + manager.reconcile_private_membership().await; manager.tick_missing_event_recovery().await; manager.tick_descendant_sync().await; } @@ -2443,6 +2462,12 @@ pub struct SyncManager { repo_sync_index: RepoSyncIndex, root_candidate_index: RootCandidateIndex, proactive_participant_authors: crate::nostr::policy::SharedProactiveParticipantAuthorIndex, + /// GRASP-08 access shared with the inbound HTTP/WebSocket boundary. + private_access: Option, + /// Operator-configured members form the permanent base of private access. + configured_private_members: HashSet, + /// Latest NIP-11 owner learned for each connected repository relay. + relay_owners: HashMap, /// What we've confirmed syncing + connection state relay_sync_index: RelaySyncIndex, /// In-flight subscription batches @@ -2544,6 +2569,7 @@ impl SyncManager { config: &Config, data_path: PathBuf, sync_metrics: Option, + private_access: Option, ) -> Self { // Extract purgatory from write_policy for read-only access let purgatory = write_policy.purgatory().clone(); @@ -2579,6 +2605,11 @@ impl SyncManager { } let proactive_participant_authors = write_policy.proactive_participant_authors(); + let configured_private_members = config + .parse_private_members() + .expect("private members were validated before SyncManager construction") + .into_iter() + .collect(); Self { bootstrap_relay_url, service_domain, @@ -2590,6 +2621,9 @@ impl SyncManager { repo_sync_index: Arc::new(RwLock::new(HashMap::new())), root_candidate_index: Arc::new(RwLock::new(HashMap::new())), proactive_participant_authors, + private_access, + configured_private_members, + relay_owners: HashMap::new(), relay_sync_index: Arc::new(RwLock::new(HashMap::new())), pending_sync_index: Arc::new(RwLock::new(HashMap::new())), rejected_events_index, @@ -5431,6 +5465,7 @@ impl SyncManager { ConnectAttemptOutcome::Connected { advertised_default_limit: hints.default_limit, advertised_max_subscriptions: hints.max_subscriptions, + advertised_owner: hints.owner, } }, Err(error) => ConnectAttemptOutcome::Failed(error), @@ -5461,6 +5496,38 @@ impl SyncManager { targets } + /// Rebuild GRASP-08 membership from accepted-announcement sync state and + /// the latest NIP-11 owner learned for each referenced relay. + /// + /// Purgatory announcements are deliberately excluded: they have not yet + /// passed repository admission and therefore cannot grant service access. + async fn reconcile_private_membership(&self) { + let Some(access) = &self.private_access else { + return; + }; + let repo_index = self.repo_sync_index.read().await; + let accepted_relays: HashSet = repo_index + .values() + .filter(|needs| needs.sync_level == SyncLevel::Full) + .flat_map(|needs| needs.relays.iter()) + .filter_map(|relay| canonical_relay_key(relay).ok()) + .collect(); + drop(repo_index); + let members = effective_private_members( + &self.configured_private_members, + &accepted_relays, + &self.relay_owners, + ); + if access.replace(members) { + tracing::info!( + configured_members = self.configured_private_members.len(), + accepted_relay_count = accepted_relays.len(), + effective_members = access.len(), + "Reconciled GRASP-08 service membership" + ); + } + } + fn configured_nip65_fallback_relays(&self) -> HashSet { self.config .parse_sync_plus_fallback_relays() @@ -6185,7 +6252,17 @@ impl SyncManager { ConnectAttemptOutcome::Connected { advertised_default_limit, advertised_max_subscriptions, + advertised_owner, } => { + match advertised_owner { + Some(owner) => { + self.relay_owners.insert(result.relay_url.clone(), owner); + } + None => { + self.relay_owners.remove(&result.relay_url); + } + } + self.reconcile_private_membership().await; if let Some(connection) = self.connections.get(&result.relay_url) { connection.reset_subscription_budget(advertised_max_subscriptions); } @@ -8915,6 +8992,23 @@ mod tests { assert!(!rejected.contains(&child.id)); } + #[test] + fn private_members_include_only_owners_of_accepted_relays() { + let configured = Keys::generate().public_key(); + let accepted_owner = Keys::generate().public_key(); + let unrelated_owner = Keys::generate().public_key(); + let members = effective_private_members( + &HashSet::from([configured]), + &HashSet::from(["wss://accepted.example/".to_string()]), + &HashMap::from([ + ("wss://accepted.example/".to_string(), accepted_owner), + ("wss://unrelated.example/".to_string(), unrelated_owner), + ]), + ); + + assert_eq!(members, HashSet::from([configured, accepted_owner])); + } + #[test] fn partial_nip65_batch_retries_missing_authors_early() { let returned = Keys::generate().public_key(); diff --git a/src/sync/relay_connection.rs b/src/sync/relay_connection.rs index f7d152d..98d4930 100644 --- a/src/sync/relay_connection.rs +++ b/src/sync/relay_connection.rs @@ -438,6 +438,28 @@ type LiveReqPermitMap = std::sync::Arc< pub struct RelayLimitHints { pub default_limit: Option, pub max_subscriptions: Option, + /// Relay operator identity advertised by NIP-11. Private GRASP services + /// grant this identity access only when the relay is referenced by an + /// accepted repository announcement. + pub owner: Option, +} + +fn parse_relay_limit_hints(body: &str) -> RelayLimitHints { + let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else { + return RelayLimitHints::default(); + }; + let limitation = document.limitation.unwrap_or_default(); + RelayLimitHints { + default_limit: limitation + .default_limit + .and_then(|limit| usize::try_from(limit).ok()) + .filter(|limit| *limit > 0), + max_subscriptions: limitation + .max_subscriptions + .and_then(|limit| usize::try_from(limit).ok()) + .filter(|limit| *limit > 0), + owner: document.pubkey, + } } /// How a failed negentropy diff should affect future NIP-77 attempts. @@ -941,23 +963,7 @@ impl RelayConnection { return RelayLimitHints::default(); } }; - let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() - else { - return RelayLimitHints::default(); - }; - let Some(limitation) = document.limitation else { - return RelayLimitHints::default(); - }; - RelayLimitHints { - default_limit: limitation - .default_limit - .and_then(|limit| usize::try_from(limit).ok()) - .filter(|limit| *limit > 0), - max_subscriptions: limitation - .max_subscriptions - .and_then(|limit| usize::try_from(limit).ok()) - .filter(|limit| *limit > 0), - } + parse_relay_limit_hints(&body) } /// Whether the SDK still considers this relay's WebSocket established. @@ -2745,6 +2751,18 @@ impl RelayConnection { mod tests { use super::*; + #[test] + fn nip11_owner_is_retained_without_a_limitation_object() { + let owner = Keys::generate().public_key(); + let body = format!(r#"{{"pubkey":"{}"}}"#, owner.to_hex()); + + let hints = parse_relay_limit_hints(&body); + + assert_eq!(hints.owner, Some(owner)); + assert_eq!(hints.default_limit, None); + assert_eq!(hints.max_subscriptions, None); + } + /// Event-directed connection with the permissive policy used by tests /// that dial loopback fixtures. fn permissive_connection(url: &str, keys: Keys) -> RelayConnection { From 84096df0a1fcb83a841d38af6686ec0ae00fe6d0 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 13 Aug 2026 21:30:06 +0000 Subject: [PATCH 3/4] test(private-repos): exercise the HTTP authentication boundary The private-service implementation spans command-line configuration, HTTP routing, canonical repository identity, and credential validation. Unit tests of those pieces cannot prove that the production subprocess composes them into a fail-closed endpoint. Extend TestRelay with an explicit private-service constructor and exercise the real process over HTTP. The scenario verifies that missing and non-member credentials receive the same empty Nostr 401 challenge while a configured member crosses the authentication boundary. A nonexistent repository is intentional: authentication is the behavior under test, and successful authorization must remain distinguishable only by proceeding to ordinary Git routing. NIP-42 protocol framing remains covered by its focused state-machine tests; duplicating that protocol through an SDK client is excluded from this process-level HTTP regression. Validation: cargo fmt --check passed; cargo test --locked --test private_mode passed (45 tests including the new subprocess scenario). --- tests/common/relay.rs | 26 +++++++++++++++++ tests/private_mode.rs | 65 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/private_mode.rs diff --git a/tests/common/relay.rs b/tests/common/relay.rs index feaf712..12d3bfe 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -87,6 +87,7 @@ struct RelayOptions { rejected_hot_cache_duration_secs: Option, relay_max_subscriptions: Option, sync_recursive_descendant_limit: Option, + private_members: Option, /// Run with the production outbound target policy (reject non-global /// event-directed sync targets). The fixture default is permissive /// because the entire test infrastructure lives on loopback. @@ -128,6 +129,23 @@ impl TestRelay { Self::start_internal(port::reserve_port(), RelayOptions::default()).await } + /// Start one GRASP-08 private service whose static membership contains + /// `member`. + pub async fn start_private(member: &nostr_sdk::prelude::PublicKey) -> Self { + Self::start_internal( + port::reserve_port(), + RelayOptions { + private_members: Some( + member + .to_bech32() + .expect("Failed to encode private test member"), + ), + ..RelayOptions::default() + }, + ) + .await + } + /// Start relay with sync from another relay (bootstrap relay) /// /// # Example @@ -638,6 +656,14 @@ impl TestRelay { if let Some(ref fallback_relays) = options.sync_plus_fallback_relays { cmd.env("NGIT_SYNC_PLUS_FALLBACK_RELAYS", fallback_relays); } + if let Some(ref private_members) = options.private_members { + cmd.env("NGIT_PRIVATE_MODE", "true") + .env("NGIT_PRIVATE_MEMBERS", private_members) + .env( + "NGIT_PRIVATE_PUBLIC_ORIGIN", + format!("http://{bind_address}"), + ); + } // The test infrastructure runs entirely on loopback, which the // production outbound target policy rejects for event-directed sync. diff --git a/tests/private_mode.rs b/tests/private_mode.rs new file mode 100644 index 0000000..f0ddc36 --- /dev/null +++ b/tests/private_mode.rs @@ -0,0 +1,65 @@ +mod common; + +use base64::Engine; +use common::TestRelay; +use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, ToBech32}; +use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE}; + +fn credential(keys: &Keys, repository_url: &str) -> String { + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(vec![ + Tag::parse(["u", repository_url]).expect("URL tag"), + Tag::parse(["method", "GET"]).expect("method tag"), + ]) + .finalize(keys) + .expect("signed NIP-98 credential"); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(event.as_json()) + ) +} + +#[tokio::test] +async fn private_git_endpoint_requires_a_service_member() { + let member = Keys::generate(); + let outsider = Keys::generate(); + let repository_owner = Keys::generate() + .public_key() + .to_bech32() + .expect("repository owner npub"); + let relay = TestRelay::start_private(&member.public_key()).await; + let repository_url = format!( + "http://{}/{repository_owner}/private-repository.git", + relay.domain() + ); + let client = reqwest::Client::new(); + + for authorization in [None, Some(credential(&outsider, &repository_url))] { + let mut request = client.get(&repository_url); + if let Some(authorization) = authorization { + request = request.header(AUTHORIZATION, authorization); + } + let response = request.send().await.expect("private Git response"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + assert_eq!( + response + .headers() + .get(WWW_AUTHENTICATE) + .expect("Nostr challenge") + .to_str() + .expect("ASCII challenge"), + format!("Nostr realm=\"{}\", method=\"GET\"", relay.domain()) + ); + assert!(response.bytes().await.expect("response body").is_empty()); + } + + let response = client + .get(&repository_url) + .header(AUTHORIZATION, credential(&member, &repository_url)) + .send() + .await + .expect("authenticated Git response"); + assert_ne!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + + relay.stop().await; +} From c169658096e262735af0825ce8426e5a6418b227 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 14 Aug 2026 22:11:07 +0000 Subject: [PATCH 4/4] test(private-repos): broaden private-mode integration coverage The GRASP-08 boundary previously had end-to-end coverage only for the HTTP repository-root credential. Exercise the remaining externally observable contracts through the subprocess fixture: - NIP-42 WebSocket lifecycle: challenge on connect, auth-required rejections for REQ/EVENT/garbage before authentication, member admission that bridges through to a served subscription, immediate restricted-close for valid non-member credentials, and the bounded three-attempt budget for invalid ones. - NIP-98 credential scope: expiry outside the 60-second window and subpath-scoped credentials both fail with the indistinguishable empty 401, while one repository-root credential is reusable across Smart HTTP endpoints. - Discovery: the NIP-11 document stays unauthenticated and advertises NIP-42/98 so clients can learn the requirement without credentials. Deliberately excluded: dynamic accepted-relay-owner admission still has only unit coverage; an end-to-end scenario needs a second announced relay fixture and is left as follow-up scope. Validation: cargo test --test private_mode (51 passed) and cargo clippy --all-targets -- -D warnings on the rebased branch. --- tests/private_mode.rs | 306 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 304 insertions(+), 2 deletions(-) diff --git a/tests/private_mode.rs b/tests/private_mode.rs index f0ddc36..6142a51 100644 --- a/tests/private_mode.rs +++ b/tests/private_mode.rs @@ -1,16 +1,30 @@ mod common; +use std::time::Duration; + use base64::Engine; use common::TestRelay; -use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, ToBech32}; -use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE}; +use futures_util::{SinkExt, StreamExt}; +use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, Timestamp, ToBech32}; +use reqwest::header::{ACCEPT, AUTHORIZATION, WWW_AUTHENTICATE}; +use tokio_tungstenite::tungstenite::Message; + +type WsStream = + tokio_tungstenite::WebSocketStream>; + +const WS_DEADLINE: Duration = Duration::from_secs(10); fn credential(keys: &Keys, repository_url: &str) -> String { + credential_at(keys, repository_url, Timestamp::now()) +} + +fn credential_at(keys: &Keys, repository_url: &str, created_at: Timestamp) -> String { let event = EventBuilder::new(Kind::HttpAuth, "") .tags(vec![ Tag::parse(["u", repository_url]).expect("URL tag"), Tag::parse(["method", "GET"]).expect("method tag"), ]) + .custom_created_at(created_at) .finalize(keys) .expect("signed NIP-98 credential"); format!( @@ -19,6 +33,69 @@ fn credential(keys: &Keys, repository_url: &str) -> String { ) } +fn auth_message(keys: &Keys, relay_domain: &str, challenge: &str) -> String { + let relay_url = format!("ws://{relay_domain}"); + let event = EventBuilder::new(Kind::Authentication, "") + .tags(vec![ + Tag::parse(["relay", relay_url.as_str()]).expect("relay tag"), + Tag::parse(["challenge", challenge]).expect("challenge tag"), + ]) + .finalize(keys) + .expect("signed NIP-42 event"); + format!("[\"AUTH\",{}]", event.as_json()) +} + +/// Await the next text frame within the bounded deadline, skipping +/// non-text control frames. +async fn next_text(stream: &mut WsStream) -> String { + tokio::time::timeout(WS_DEADLINE, async { + while let Some(message) = stream.next().await { + if let Message::Text(text) = message.expect("valid websocket frame") { + return text.to_string(); + } + } + panic!("websocket closed while awaiting a relay message"); + }) + .await + .expect("relay message within deadline") +} + +/// Connect to a private relay and consume the initial NIP-42 challenge. +async fn connect_and_challenge(relay: &TestRelay) -> (WsStream, String) { + let (mut stream, _) = tokio_tungstenite::connect_async(relay.url()) + .await + .expect("connect to private relay"); + let frame: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON message"); + assert_eq!(frame[0], "AUTH", "first relay message must be the challenge"); + let challenge = frame[1].as_str().expect("challenge string").to_owned(); + (stream, challenge) +} + +async fn send_text(stream: &mut WsStream, text: String) { + tokio::time::timeout(WS_DEADLINE, stream.send(Message::Text(text.into()))) + .await + .expect("send within deadline") + .expect("send websocket frame"); +} + +/// Assert the peer terminates the connection without further relay messages. +async fn expect_closed(stream: &mut WsStream) { + tokio::time::timeout(WS_DEADLINE, async { + loop { + match stream.next().await { + None | Some(Ok(Message::Close(_))) | Some(Err(_)) => return, + Some(Ok(Message::Text(text))) => { + panic!("expected connection close, received: {text}") + } + Some(Ok(_)) => {} + } + } + }) + .await + .expect("connection close within deadline"); +} + #[tokio::test] async fn private_git_endpoint_requires_a_service_member() { let member = Keys::generate(); @@ -63,3 +140,228 @@ async fn private_git_endpoint_requires_a_service_member() { relay.stop().await; } + +#[tokio::test] +async fn private_git_credential_expiry_and_smart_http_scope() { + let member = Keys::generate(); + let repository_owner = Keys::generate() + .public_key() + .to_bech32() + .expect("repository owner npub"); + let relay = TestRelay::start_private(&member.public_key()).await; + let repository_url = format!( + "http://{}/{repository_owner}/private-repository.git", + relay.domain() + ); + let info_refs_url = format!("{repository_url}/info/refs?service=git-upload-pack"); + let client = reqwest::Client::new(); + + // A credential outside the 60-second validity window is indistinguishable + // from any other failure: same empty 401 challenge. + let expired = credential_at( + &member, + &repository_url, + Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(300)), + ); + let response = client + .get(&info_refs_url) + .header(AUTHORIZATION, expired) + .send() + .await + .expect("expired credential response"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + assert!(response.bytes().await.expect("response body").is_empty()); + + // A credential signing the Smart HTTP subpath instead of the repository + // root does not match the GRASP-08 canonical URL. + let response = client + .get(&info_refs_url) + .header(AUTHORIZATION, credential(&member, &info_refs_url)) + .send() + .await + .expect("subpath-scoped credential response"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + + // The repository-root credential is reusable across Smart HTTP endpoints + // within its validity window. + let reusable = credential(&member, &repository_url); + for url in [&info_refs_url, &repository_url] { + let response = client + .get(url) + .header(AUTHORIZATION, reusable.clone()) + .send() + .await + .expect("member Smart HTTP response"); + assert_ne!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + } + + relay.stop().await; +} + +#[tokio::test] +async fn private_nip11_document_stays_public_and_advertises_auth() { + let member = Keys::generate(); + let relay = TestRelay::start_private(&member.public_key()).await; + + // Clients must be able to discover the authentication requirement without + // credentials, so the NIP-11 document is deliberately unauthenticated. + let response = reqwest::Client::new() + .get(format!("http://{}/", relay.domain())) + .header(ACCEPT, "application/nostr+json") + .send() + .await + .expect("NIP-11 response"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let document: serde_json::Value = response.json().await.expect("NIP-11 JSON"); + let nips = document["supported_nips"] + .as_array() + .expect("supported_nips array"); + for nip in [42, 98] { + assert!( + nips.contains(&serde_json::Value::from(nip)), + "NIP-11 must advertise NIP-{nip}: {nips:?}" + ); + } + + relay.stop().await; +} + +#[tokio::test] +async fn private_websocket_rejects_messages_before_authentication() { + let member = Keys::generate(); + let relay = TestRelay::start_private(&member.public_key()).await; + let (mut stream, _challenge) = connect_and_challenge(&relay).await; + + send_text(&mut stream, r#"["REQ","pre-auth",{}]"#.to_string()).await; + let closed: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("CLOSED JSON"); + assert_eq!(closed[0], "CLOSED"); + assert_eq!(closed[1], "pre-auth"); + assert!( + closed[2] + .as_str() + .expect("CLOSED message") + .starts_with("auth-required:"), + "{closed}" + ); + + let note = EventBuilder::new(Kind::TextNote, "pre-auth publish") + .finalize(&member) + .expect("signed note"); + send_text(&mut stream, format!("[\"EVENT\",{}]", note.as_json())).await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[0], "OK"); + assert_eq!(ok[1].as_str(), Some(note.id.to_hex().as_str())); + assert_eq!(ok[2], false); + assert!( + ok[3].as_str() + .expect("OK message") + .starts_with("auth-required:"), + "{ok}" + ); + + send_text(&mut stream, "not a nostr message".to_string()).await; + let notice: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("NOTICE JSON"); + assert_eq!(notice[0], "NOTICE"); + + relay.stop().await; +} + +#[tokio::test] +async fn private_websocket_admits_member_and_bridges_to_relay() { + let member = Keys::generate(); + let relay = TestRelay::start_private(&member.public_key()).await; + let (mut stream, challenge) = connect_and_challenge(&relay).await; + + send_text( + &mut stream, + auth_message(&member, &relay.domain(), &challenge), + ) + .await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[0], "OK"); + assert_eq!(ok[2], true, "member NIP-42 authentication must succeed: {ok}"); + + // The authenticated session reaches the inner relay: a subscription is + // answered with EOSE instead of an auth-required rejection. + send_text( + &mut stream, + r#"["REQ","after-auth",{"kinds":[1],"limit":1}]"#.to_string(), + ) + .await; + let deadline = tokio::time::timeout(WS_DEADLINE, async { + loop { + let frame: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON"); + if frame[0] == "EOSE" && frame[1] == "after-auth" { + return; + } + assert_ne!(frame[0], "CLOSED", "authenticated REQ was rejected: {frame}"); + } + }) + .await; + assert!(deadline.is_ok(), "no EOSE for authenticated subscription"); + + relay.stop().await; +} + +#[tokio::test] +async fn private_websocket_rejects_valid_nonmember_auth_and_closes() { + let member = Keys::generate(); + let outsider = Keys::generate(); + let relay = TestRelay::start_private(&member.public_key()).await; + let (mut stream, challenge) = connect_and_challenge(&relay).await; + + send_text( + &mut stream, + auth_message(&outsider, &relay.domain(), &challenge), + ) + .await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[0], "OK"); + assert_eq!(ok[2], false); + assert!( + ok[3].as_str() + .expect("OK message") + .starts_with("restricted:"), + "valid non-member auth must be restricted: {ok}" + ); + expect_closed(&mut stream).await; + + relay.stop().await; +} + +#[tokio::test] +async fn private_websocket_bounds_invalid_authentication_attempts() { + let member = Keys::generate(); + let outsider = Keys::generate(); + let relay = TestRelay::start_private(&member.public_key()).await; + let (mut stream, _challenge) = connect_and_challenge(&relay).await; + + // Three syntactically valid AUTH events signed over the wrong challenge + // exhaust the attempt budget and terminate the connection. + for _ in 0..3 { + send_text( + &mut stream, + auth_message(&outsider, &relay.domain(), "wrong-challenge"), + ) + .await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[0], "OK"); + assert_eq!(ok[2], false); + assert!( + ok[3].as_str() + .expect("OK message") + .starts_with("auth-required:"), + "{ok}" + ); + } + expect_closed(&mut stream).await; + + relay.stop().await; +}