diff --git a/docs/explanation/README.md b/docs/explanation/README.md index 6233798..fabb701 100644 --- a/docs/explanation/README.md +++ b/docs/explanation/README.md @@ -201,6 +201,20 @@ Explanation documentation helps you **understand concepts** and design decisions --- +### [GRASP-08 Private Service Authentication](grasp-08-private-service.md) +**Service-wide NIP-42/NIP-98 authentication for private repositories** + +**Topics:** +- Fail-closed private mode and indistinguishable 401 responses +- The GRASP-08 repository-scoped NIP-98 profile vs generic NIP-98 +- NIP-42 authentication outside the embedded relay +- Service-wide membership and dynamic accepted-relay-owner admission +- Trust model and follow-up scope + +**Read when:** You want to understand how a private GRASP instance authenticates clients and peers + +--- + ### [Repository Lifecycle](repository-lifecycle.md) **Handling repository removal, holding, archive, recovery, and purgatory** diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index defcaaa..bca67b1 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -686,14 +686,23 @@ Optional endpoint at `/prs//.git`, gated on `NGIT_GRASP06_ENAB ## 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. +single `PrivateAccess` set is shared by the HTTP and WebSocket services and +the announcement admission policy. Push authorization remains the GRASP-01 +policy; 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. + +Because accepted announcements drive membership, announcement admission is +itself membership-gated in private mode: a kind-30617 event is only admitted +when its author is a current effective member at admission time, on every +arrival path (direct publish, sync import, purgatory promotion). Non-member +announcements are rejected through the normal announcement rejection +machinery. State events (kind 30618) keep the GRASP-01 maintainer rules, and +removal is non-retroactive — repositories admitted while their author was a +member remain hosted until the operator curates them. For Nostr, Hyper completes the public WebSocket upgrade and a message-level proxy sends and validates NIP-42 authentication before a connection reaches diff --git a/docs/explanation/grasp-08-private-service.md b/docs/explanation/grasp-08-private-service.md new file mode 100644 index 0000000..93d12f6 --- /dev/null +++ b/docs/explanation/grasp-08-private-service.md @@ -0,0 +1,181 @@ +# GRASP-08: Private Service Authentication — Design + +**Status**: Implemented, single-service scope (opt-in via `NGIT_PRIVATE_MODE`) + +**Spec**: [GRASP-08](https://github.com/DanConwayDev/grasp/blob/main/08.md) +**Related**: [Architecture](architecture.md), [Defensive Measures](defensive-measures.md), [Repository Lifecycle](repository-lifecycle.md) +**Configuration**: [`NGIT_PRIVATE_MODE`, `NGIT_PRIVATE_MEMBERS`, `NGIT_PRIVATE_PUBLIC_ORIGIN`](../reference/configuration.md) + +--- + +## Overview + +GRASP-08 turns a GRASP service into a private one: repository events and Git +objects must not be readable merely because an endpoint is reachable. Every +Nostr WebSocket session must authenticate with NIP-42 and every standard Git +Smart HTTP request must carry a repository-scoped NIP-98 credential before any +repository data is served. + +Private mode is a **service-wide access boundary**, not a per-repository ACL. +A private instance is one trust domain: everything it hosts is readable by +every member and by nobody else. Push authorization is unchanged — a private +credential proves membership, and GRASP-01's maintainer-based push rules still +decide who may write which repository. + +## What GRASP-08 changes + +1. **WebSocket**: after the public upgrade, a message-level proxy issues a + NIP-42 `AUTH` challenge and validates the response before the connection is + bridged to the embedded relay. Unauthenticated `REQ`/`EVENT`/`COUNT`/ + `NEG-OPEN` messages receive machine-readable `auth-required:` rejections; + a valid authentication by a non-member receives `restricted:` and the + connection is closed. Three invalid attempts or thirty seconds of silence + terminate the connection. +2. **Git Smart HTTP**: requests under `//.git` must carry the + GRASP-08 profile of NIP-98 (see below). Authentication runs before + repository lookup or request-body collection. +3. **Membership**: one shared member set combines operator-configured npubs + (`NGIT_PRIVATE_MEMBERS`) with the NIP-11 owner pubkeys of relays referenced + by *accepted* repository announcements. Membership changes propagate to + live WebSocket sessions, which are closed when their pubkey is removed. +4. **Discovery stays public**: the NIP-11 document (which advertises NIPs 42 + and 98), the NIP-05 root identity, the landing page, and the icon remain + unauthenticated so clients can discover the authentication requirement. + This deliberately discloses the operator identity and the service's + existence — private mode hides repository *content*, not the service. + +Everything else — announcement purgatory, proactive sync, push authorization, +repository lifecycle — is unchanged. + +## Why this shape + +### Why every failure is the same empty 401 + +Missing, malformed, expired, and non-member Git credentials all receive an +identical empty `401 Unauthorized` with the same `WWW-Authenticate: Nostr` +challenge. Distinguishable failures would let an unauthenticated party probe +which repositories exist or which pubkeys are members. Authentication runs +before repository lookup for the same reason: a 404-before-auth would be an +existence oracle. + +### Why the Git credential differs from generic NIP-98 + +Generic NIP-98 signs the exact request URL and method and is single-use. A Git +clone is not one request — it is a sequence of `info/refs` and pack-transfer +requests, issued by tooling that cannot re-sign per request. The GRASP-08 +profile therefore signs the **canonical repository root** with method `GET`, +ignores payload tags, and is reusable across the standard Smart HTTP endpoints +for a 60-second validity window. Replay within the window is accepted: the +credential grants read access the holder already has for that window, and +write operations remain gated by GRASP-01 push authorization, so replay +confers nothing beyond what the member could do anyway. + +The canonical public origin is operator-controlled +(`NGIT_PRIVATE_PUBLIC_ORIGIN`, falling back to `NGIT_DOMAIN`) so that a +reverse proxy cannot influence the identity that credentials sign. + +### Why NIP-42 runs outside the embedded relay + +`nostr-relay-builder`'s query and write policies do not receive the +authenticated session pubkey, so the access check cannot live inside them. +Instead the proxy authenticates first and only then bridges frames through an +in-memory WebSocket pair to `LocalRelay`. The bridge subscribes to a +membership generation channel; revoking a member closes their live sessions +instead of letting them ride out an old connection. + +### Why membership is service-wide + +Relays are referenced per repository in NIP-34 announcements, but access here +is per service. Reconciling the two per-repository would require per-session +subscription filtering inside the embedded relay (which the policy interfaces +cannot express, see above) and would turn every query into an ACL join. The +single-service model instead declares the whole instance one trust domain — +the deployment it targets is a team running a private service for its own +repositories. Hosting mutually-distrusting sub-groups on one instance is +explicitly out of scope and belongs to the future multi-service/fleet +proposal. Under this model, per-repository relay lists act as **sync topology +hints** (who to connect to), while the service-wide member set is the **ACL** +(who may read). + +### Why accepted-relay owners are admitted dynamically + +Two private services mirroring the same repository must be able to read from +each other. When an accepted announcement references another relay, that +relay's NIP-11 `pubkey` is added to the member set, so a peer service (or the +operator of an ordinary relay the team uses) can authenticate without manual +whitelisting on both sides. The consequence — accepting one announcement +grants its referenced relay operators read access to the whole service — +follows directly from the one-trust-domain model and is the operator's +opt-in via announcement admission. + +Discovery reuses the NIP-11 document already fetched once per sync +connection, and reconciliation rides the existing five-second maintenance +pass, so dynamic membership adds no polling, subscriptions, connections, or +background tasks. + +### Why announcement admission is membership-gated + +Accepted announcements are not just hosting decisions: the NIP-11 owners of +their referenced relays become members. Left ungated, a member could submit a +third-party-signed kind-30617 announcement — or sync could import one from an +operator-configured source — and thereby mint membership for pubkeys no +member ever chose, which those pubkeys' relays could amplify further with +announcements of their own. In private mode an announcement is therefore only +admitted when its author (the event pubkey) is a current effective member, +evaluated against the live member set at admission time; every arrival path +(direct publish, sync import, purgatory promotion) funnels through the same +admission policy. This closes the loop: hosting and derived membership can +only expand through member action. State events (kind 30618) stay governed by +GRASP-01 maintainer rules, and removal remains non-retroactive — an admitted +repository is not evicted when its author later leaves the member set. + +### Why purgatory announcements grant nothing + +Purgatory holds announcements that have *not* passed repository admission. +If purgatory state could contribute relay owners to the member set, anyone +able to get an event into purgatory could mint members. Only announcements +accepted into the repository index count. + +### Why private mode refuses to combine with GRASP-06 + +The GRASP-06 contributor endpoint (`/prs/`) is intentionally unauthenticated — +its entire design premise is that any contributor can push a PR (see +[GRASP-06 design](grasp-06-contributor-pr-submission.md)). That premise is +incompatible with a private service, so `NGIT_PRIVATE_MODE=true` together +with `NGIT_GRASP06_ENABLE=true` is a fatal configuration error rather than a +silently half-open service. + +### Why configuration fails closed + +Private mode without members would lock everyone out silently, and members +without private mode would suggest an operator believes the service is +private when it is not. Both are startup errors. Malformed member npubs are +fatal rather than skipped: dropping an access-control entry would silently +lock a user out. + +## Trust model summary + +- **Configured members** (`NGIT_PRIVATE_MEMBERS`) are the permanent base set, + trusted by operator assertion. +- **Accepted-relay owners** are derived members, trusted transitively via + announcement admission plus the referenced relay's NIP-11 self-assertion + (the same HTTPS-from-domain trust anchor a `_@domain` NIP-05 lookup would + provide, without an extra fetch or format). +- **Membership grants read access only.** Push authorization remains + GRASP-01's maintainer model; repository admission remains announcement + policy, which in private mode additionally requires the announcement + author to be a current effective member. +- **Removal is not retroactive.** Removing a member closes their sessions and + invalidates future credentials, but repositories admitted while they were a + member remain hosted until the operator curates them. + +## Follow-up scope + +Deliberately excluded from the initial single-service implementation: + +- **Outbound authentication**: presenting NIP-42 and GRASP-08 NIP-98 + credentials when syncing *from* other private services, using a service + identity key. This is the missing half of zero-configuration private + mirroring. +- **Multi-service fleet orchestration** and **encrypted kind-10318 client + discovery**, which belong to future GRASP proposals. diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 6f0accd..3be99d7 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -32,6 +32,7 @@ use crate::nostr::policy::{ StateResult, }; use crate::nostr::SharedDatabase; +use crate::private::PrivateAccess; use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks; use crate::sync::rejected_index::RejectedEventsIndex; @@ -112,6 +113,7 @@ impl Nip34WritePolicy { purgatory: std::sync::Arc, config: crate::config::Config, repo_init_locks: crate::grasp06::receive::RepoInitLocks, + private_access: Option, ) -> Self { let git_data_path = git_data_path.into(); let domain = config.domain.clone(); @@ -130,7 +132,7 @@ impl Nip34WritePolicy { let ctx = PolicyContext::new(domain, database, git_data_path, purgatory, config.clone()); Self { - announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()), + announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone(), private_access), state_policy: StatePolicy::new(ctx.clone()), pr_event_policy: PrEventPolicy::new(ctx.clone(), repo_init_locks), related_event_policy: RelatedEventPolicy::new(ctx.clone()), @@ -939,6 +941,7 @@ pub async fn create_relay( config: &Config, purgatory: Arc, repo_init_locks: crate::grasp06::receive::RepoInitLocks, + private_access: Option, ) -> Result { tracing::info!("Configuring nostr relay with GRASP-01 validation..."); @@ -1052,6 +1055,7 @@ pub async fn create_relay( purgatory, config.clone(), repo_init_locks, + private_access, ); let mut builder = LocalRelayBuilder::default() diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs index 33e2081..1acf0a1 100644 --- a/src/nostr/policy/announcement.rs +++ b/src/nostr/policy/announcement.rs @@ -9,6 +9,7 @@ use std::time::Duration; use super::PolicyContext; use crate::config::Config; use crate::nostr::events::{validate_announcement, RepositoryAnnouncement}; +use crate::private::PrivateAccess; /// Result of announcement policy evaluation #[derive(Debug, Clone, PartialEq)] @@ -30,11 +31,21 @@ pub enum AnnouncementResult { pub struct AnnouncementPolicy { ctx: PolicyContext, config: Config, + /// GRASP-08 live effective member set; `Some` only in private mode. + private_access: Option, } impl AnnouncementPolicy { - pub fn new(ctx: PolicyContext, config: Config) -> Self { - Self { ctx, config } + pub fn new( + ctx: PolicyContext, + config: Config, + private_access: Option, + ) -> Self { + Self { + ctx, + config, + private_access, + } } /// Validate a repository announcement event @@ -47,6 +58,21 @@ impl AnnouncementPolicy { /// - `AcceptArchive` if accepted via GRASP-05 archive config /// - `Reject` with reason if validation fails pub async fn validate(&self, event: &Event) -> AnnouncementResult { + // GRASP-08: on a private service, admitting an announcement expands + // hosting and — via its referenced relays' NIP-11 owners — the member + // set itself. Only a current effective member may therefore introduce + // one; the check runs against the live member set at admission time + // and covers every arrival path, since all announcement admission + // funnels through this method. Removal is not retroactive: + // repositories admitted while their author was a member stay hosted. + if let Some(access) = &self.private_access { + if !access.contains(&event.pubkey) { + return AnnouncementResult::Reject( + "Announcement author is not authorized on this private service".to_string(), + ); + } + } + // First, try validation (GRASP-01 + GRASP-05) let validation_result = validate_announcement(event, &self.config); @@ -479,3 +505,85 @@ impl AnnouncementPolicy { Ok(false) } } + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Tag, ToBech32}; + use std::path::PathBuf; + use std::sync::Arc; + + fn policy(private_access: Option) -> AnnouncementPolicy { + let config = Config::for_testing(); + let ctx = PolicyContext::new_for_test( + config.domain.clone(), + Arc::new(nostr_memory::MemoryDatabase::unbounded()), + PathBuf::new(), + Arc::new(crate::purgatory::Purgatory::new(PathBuf::new())), + config.clone(), + ); + AnnouncementPolicy::new(ctx, config, private_access) + } + + /// A GRASP-01-valid announcement listing the test service in both the + /// `clone` and `relays` tags. + fn service_announcement(keys: &Keys, domain: &str) -> Event { + let npub = keys.public_key().to_bech32().expect("author npub"); + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![ + Tag::identifier("membership-gate-repo"), + Tag::custom( + "clone", + [format!("https://{domain}/{npub}/membership-gate-repo.git")], + ), + Tag::custom("relays", [format!("wss://{domain}")]), + ]) + .finalize(keys) + .expect("signed announcement") + } + + #[tokio::test] + async fn private_mode_rejects_announcement_from_nonmember_author() { + let member = Keys::generate(); + let outsider = Keys::generate(); + let policy = policy(Some(PrivateAccess::new([member.public_key()]))); + + let event = service_announcement(&outsider, &policy.config.domain); + let result = policy.validate(&event).await; + + assert!( + matches!(result, AnnouncementResult::Reject(_)), + "non-member author must be rejected in private mode: {result:?}" + ); + } + + #[tokio::test] + async fn private_mode_accepts_announcement_from_member_author() { + let member = Keys::generate(); + let policy = policy(Some(PrivateAccess::new([member.public_key()]))); + + let event = service_announcement(&member, &policy.config.domain); + let result = policy.validate(&event).await; + + assert_eq!( + result, + AnnouncementResult::AcceptPurgatory, + "member-authored announcement must pass the membership gate" + ); + } + + #[tokio::test] + async fn public_mode_ignores_the_membership_gate() { + let author = Keys::generate(); + let policy = policy(None); + + let event = service_announcement(&author, &policy.config.domain); + let result = policy.validate(&event).await; + + assert_eq!( + result, + AnnouncementResult::AcceptPurgatory, + "public mode admission must be unchanged" + ); + } +} diff --git a/src/server.rs b/src/server.rs index d20cb98..c35bfd0 100644 --- a/src/server.rs +++ b/src/server.rs @@ -188,10 +188,14 @@ impl RelayServer { } // Create Nostr relay runtime with NIP-34 validation and shared stores. - let relay_runtime = - nostr::builder::create_relay(&config, purgatory.clone(), repo_init_locks.clone()) - .await - .context("failed to create relay runtime")?; + let relay_runtime = nostr::builder::create_relay( + &config, + purgatory.clone(), + repo_init_locks.clone(), + private_access.clone(), + ) + .await + .context("failed to create relay runtime")?; info!( "Relay created with NIP-34 validation for domain: {}", diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 761dafd..8e94f74 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -8932,6 +8932,7 @@ mod tests { &config, purgatory, crate::grasp06::receive::RepoInitLocks::default(), + None, ) .await .expect("create test relay runtime"); diff --git a/tests/git_response_streaming.rs b/tests/git_response_streaming.rs index 6128250..5f64ceb 100644 --- a/tests/git_response_streaming.rs +++ b/tests/git_response_streaming.rs @@ -433,6 +433,7 @@ fn test_write_policy( purgatory, config, repo_init_locks, + None, ) } diff --git a/tests/lifecycle/nip09_blacklist_ops.rs b/tests/lifecycle/nip09_blacklist_ops.rs index bedb4d4..6365933 100644 --- a/tests/lifecycle/nip09_blacklist_ops.rs +++ b/tests/lifecycle/nip09_blacklist_ops.rs @@ -111,6 +111,7 @@ fn make_policy( purgatory, config, new_repo_init_locks(), + None, ) } diff --git a/tests/lifecycle/replaceable_history.rs b/tests/lifecycle/replaceable_history.rs index 5c9cc1f..8c56d62 100644 --- a/tests/lifecycle/replaceable_history.rs +++ b/tests/lifecycle/replaceable_history.rs @@ -133,6 +133,7 @@ fn build_policy_for_history_regression( purgatory, config, new_repo_init_locks(), + None, ) } diff --git a/tests/private_mode.rs b/tests/private_mode.rs index 6142a51..bbd9d86 100644 --- a/tests/private_mode.rs +++ b/tests/private_mode.rs @@ -335,6 +335,85 @@ async fn private_websocket_rejects_valid_nonmember_auth_and_closes() { relay.stop().await; } +/// Build a GRASP-01-valid repository announcement listing this relay in +/// both the `clone` and `relays` tags. +fn announcement(keys: &Keys, relay_domain: &str) -> nostr_sdk::prelude::Event { + let npub = keys + .public_key() + .to_bech32() + .expect("announcement author npub"); + let clone_url = format!("https://{relay_domain}/{npub}/private-membership-repo.git"); + let relay_url = format!("ws://{relay_domain}"); + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![ + Tag::parse(["d", "private-membership-repo"]).expect("d tag"), + Tag::parse(["clone", clone_url.as_str()]).expect("clone tag"), + Tag::parse(["relays", relay_url.as_str()]).expect("relays tag"), + ]) + .finalize(keys) + .expect("signed announcement") +} + +/// Bare-repository path an admitted announcement by `keys` would create. +fn bare_repo_path(relay: &TestRelay, keys: &Keys) -> std::path::PathBuf { + relay + .git_data_path() + .join(keys.public_key().to_bech32().expect("owner npub")) + .join("private-membership-repo.git") +} + +#[tokio::test] +async fn private_announcement_admission_requires_member_author() { + 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(&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[2], true, "member NIP-42 authentication must succeed: {ok}"); + + // A member-authored announcement is admitted (OK true, parked in + // purgatory awaiting git data) and its bare repository is created. + let admitted = announcement(&member, &relay.domain()); + send_text(&mut stream, format!("[\"EVENT\",{}]", admitted.as_json())).await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[1].as_str(), Some(admitted.id.to_hex().as_str())); + assert_eq!( + ok[2], true, + "member-authored announcement must be admitted: {ok}" + ); + assert!( + bare_repo_path(&relay, &member).exists(), + "admitted announcement must create its bare repository" + ); + + // The same authenticated member session cannot introduce a valid + // announcement signed by a non-member author: membership gates the + // announcement's author, not the publishing session. + let rejected = announcement(&outsider, &relay.domain()); + send_text(&mut stream, format!("[\"EVENT\",{}]", rejected.as_json())).await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[1].as_str(), Some(rejected.id.to_hex().as_str())); + assert_eq!( + ok[2], false, + "non-member-authored announcement must be rejected: {ok}" + ); + assert!( + !bare_repo_path(&relay, &outsider).exists(), + "rejected announcement must not admit a repository" + ); + + relay.stop().await; +} + #[tokio::test] async fn private_websocket_bounds_invalid_authentication_attempts() { let member = Keys::generate();