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 index 62fa207..93d12f6 100644 --- a/docs/explanation/grasp-08-private-service.md +++ b/docs/explanation/grasp-08-private-service.md @@ -113,6 +113,22 @@ 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. @@ -147,7 +163,8 @@ lock a user out. 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. + 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. @@ -156,11 +173,6 @@ lock a user out. Deliberately excluded from the initial single-service implementation: -- **Membership-gated announcement admission**: requiring announcement authors - to be members, so hosting and derived membership can only expand through - member action. (Non-members cannot reach the relay to publish, but members - can currently submit third-party-signed announcements, and sync can import - them from operator-configured sources.) - **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 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();