Merge #3fa7d8ad: feat(private-repos): add GRASP-08 service authenticati…

feat(private-repos): add GRASP-08 service authentication

nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqsrlf7c45djx8qdst0jg764n8n6wfrf05gffncjfnym3ceguy6k3pgezg30q

PR-Author: DanConwayDev's Agent
nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0

PR description:

Implements the mergeable single-service GRASP-08 authentication boundary on the existing canonical repository paths. GRASP-08 does not require another path component: Git remains at /<npub>/<identifier>.git, while service-fleet routing is deliberately left for a future proposal.

Private mode fails closed. WebSocket access authenticates with NIP-42 before traffic reaches the relay, and Smart HTTP uses the reusable repository-scoped GRASP-08 form of NIP-98. Missing, malformed, expired, or non-member credentials receive the same empty 401 response. The canonical public origin is operator-controlled, paths remain contained under the Git root, and ordinary GRASP-01 push authorization still applies after service authentication.

Membership combines explicitly configured pubkeys with the NIP-11 owners of relays referenced by accepted repository announcements. It reuses the existing once-per-connection NIP-11 fetch and five-second maintenance pass, so the feature adds no polling, subscriptions, relay connections, or background task. Owners learned only through unaccepted purgatory state cannot grant access.

The three atomic commits separate the authentication boundary, dynamic accepted-relay membership, and subprocess integration coverage. Configuration is synchronized across source, reference docs, NixOS module, and example environment; architecture documentation records the implemented single-service scope.

Validation:
- cargo test --locked --lib: 759 passed;
- private service subprocess scenario: 45 passed, including missing/non-member rejection and member admission;
- Smart HTTP streaming: 3 passed;
- NIP-01/WebSocket compliance: 52 passed;
- cargo clippy --all-targets -- -D warnings: passed;
- nix build .#ngit-grasp: passed.

Resource assessment: inactive private mode has no runtime path change. Active private mode adds one shared membership set, one generation watch, per-session NIP-42 bridging, and membership reconciliation on the existing maintenance cadence. Its relay-owner discovery reuses NIP-11 state already fetched for sync limits.

Recommendation: ready to merge as the GRASP-08 single-service foundation. Multi-service fleet orchestration and encrypted kind-10318 client discovery remain explicit follow-up scope.
This commit is contained in:
DanConwayDev
2026-08-15 07:53:03 +01:00
19 changed files with 1593 additions and 85 deletions
+26
View File
@@ -299,6 +299,32 @@
# 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
# 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 <npubs>
# 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)
# ============================================================================
+1
View File
@@ -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"
+39
View File
@@ -683,6 +683,45 @@ Optional endpoint at `/prs/<npub>/<identifier>.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.
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
`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.
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
+53
View File
@@ -998,6 +998,59 @@ 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:** Permanently configured members of the service-wide private-service whitelist
**Type:** Comma-separated npubs
**Default:** Empty
**Required:** Yes when `NGIT_PRIVATE_MODE=true`
```bash
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
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`
+34
View File
@@ -390,6 +390,37 @@ 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 = ''
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.
'';
};
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 +602,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;
+80
View File
@@ -591,6 +591,29 @@ 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,
/// Permanently configured GRASP-08 members as comma-separated npubs.
///
/// 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,
/// 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 +929,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 +1078,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<Vec<PublicKey>> {
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 +1259,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,
+107 -27
View File
@@ -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<PathBuf> {
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");
+72 -41
View File
@@ -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<PrivateAccess>,
}
impl HttpService {
@@ -147,6 +116,7 @@ impl HttpService {
lifecycle: Arc<RepositoryLifecycle>,
rejected_events_index: Arc<RejectedEventsIndex>,
repo_init_locks: RepoInitLocks,
private_access: Option<PrivateAccess>,
) -> Self {
Self {
relay,
@@ -159,6 +129,7 @@ impl HttpService {
lifecycle,
rejected_events_index,
repo_init_locks,
private_access,
}
}
}
@@ -431,6 +402,24 @@ impl Service<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<Request<Incoming>> 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<RepositoryLifecycle>,
rejected_events_index: Arc<RejectedEventsIndex>,
repo_init_locks: RepoInitLocks,
private_access: Option<PrivateAccess>,
) -> 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<RepositoryLifecycle>,
rejected_events_index: Arc<RejectedEventsIndex>,
repo_init_locks: RepoInitLocks,
private_access: Option<PrivateAccess>,
) -> 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 {
+7
View File
@@ -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
},
+1
View File
@@ -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;
+99
View File
@@ -0,0 +1,99 @@
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<Inner>,
}
#[derive(Debug)]
struct Inner {
members: RwLock<HashSet<PublicKey>>,
generation: watch::Sender<u64>,
}
impl PrivateAccess {
pub fn new(members: impl IntoIterator<Item = PublicKey>) -> 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<Item = PublicKey>) -> bool {
let members = members.into_iter().collect();
let mut current = self
.inner
.members
.write()
.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<u64> {
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));
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);
}
}
+10
View File
@@ -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;
+248
View File
@@ -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<B>(
request: &Request<B>,
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<String> {
// 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<GitResponseBody> {
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::<std::net::Ipv4Addr>()
.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<Tag>,
) -> 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\""
);
}
}
+279
View File
@@ -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<S>(
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<S>(
public: &mut WebSocketStream<S>,
relay_domain: &str,
challenge: &str,
access: &PrivateAccess,
) -> Result<PublicKey, String>
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<S>(
public: &mut WebSocketStream<S>,
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<S>(
mut public: WebSocketStream<S>,
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<S>(
socket: &mut WebSocketStream<S>,
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");
}
}
+15
View File
@@ -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<RepositoryLifecycle>,
rejected_events_index: Arc<RejectedEventsIndex>,
repo_init_locks: crate::grasp06::receive::RepoInitLocks,
private_access: Option<PrivateAccess>,
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: {}",
@@ -221,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() {
@@ -375,6 +388,7 @@ impl RelayServer {
purgatory,
rejected_events_index,
repo_init_locks,
private_access,
deletion_cleanup,
background_tasks,
git_data_path,
@@ -414,6 +428,7 @@ impl RelayServer {
self.lifecycle,
self.rejected_events_index.clone(),
self.repo_init_locks,
self.private_access,
);
let result = tokio::select! {
+94
View File
@@ -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<PublicKey>,
accepted_relays: &HashSet<String>,
relay_owners: &HashMap<String, PublicKey>,
) -> HashSet<PublicKey> {
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<usize>,
advertised_max_subscriptions: Option<usize>,
advertised_owner: Option<PublicKey>,
},
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<PrivateAccess>,
/// Operator-configured members form the permanent base of private access.
configured_private_members: HashSet<PublicKey>,
/// Latest NIP-11 owner learned for each connected repository relay.
relay_owners: HashMap<String, PublicKey>,
/// 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<SyncMetrics>,
private_access: Option<PrivateAccess>,
) -> 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<String> = 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<String> {
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();
+35 -17
View File
@@ -438,6 +438,28 @@ type LiveReqPermitMap = std::sync::Arc<
pub struct RelayLimitHints {
pub default_limit: Option<usize>,
pub max_subscriptions: Option<usize>,
/// 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<PublicKey>,
}
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 {
+26
View File
@@ -87,6 +87,7 @@ struct RelayOptions {
rejected_hot_cache_duration_secs: Option<u64>,
relay_max_subscriptions: Option<usize>,
sync_recursive_descendant_limit: Option<usize>,
private_members: Option<String>,
/// 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.
+367
View File
@@ -0,0 +1,367 @@
mod common;
use std::time::Duration;
use base64::Engine;
use common::TestRelay;
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<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
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!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(event.as_json())
)
}
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();
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;
}
#[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;
}