mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-09-14 04:45:07 +00:00
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.
This commit is contained in:
@@ -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 <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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -683,6 +683,34 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 +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,
|
||||
|
||||
+107
-27
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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>) {
|
||||
*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<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));
|
||||
|
||||
access.replace([second]);
|
||||
assert!(!access.contains(&first));
|
||||
assert!(access.contains(&second));
|
||||
assert_eq!(*generation.borrow(), 1);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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: {}",
|
||||
@@ -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! {
|
||||
|
||||
Reference in New Issue
Block a user