diff --git a/.env.example b/.env.example index cba21cc..81380df 100644 --- a/.env.example +++ b/.env.example @@ -311,8 +311,10 @@ # Default: false # NGIT_PRIVATE_MODE=false -# Service-wide member whitelist as comma-separated npubs. Every entry is -# validated at startup; malformed entries fail closed. +# Permanently configured service-wide members as comma-separated npubs. Every +# entry is validated at startup; malformed entries fail closed. The effective +# GRASP-08 whitelist also includes NIP-11 owners of relays referenced by +# accepted repository announcements. # # CLI: --private-members # Default: (empty; at least one member is required in private mode) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 7f0db17..defcaaa 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -689,6 +689,11 @@ Private mode is an optional access layer around the normal GRASP runtime. A single `PrivateAccess` set is shared by the HTTP and WebSocket services. Repository admission and push authorization remain the GRASP-01 policies; a private credential proves service membership but never grants push rights. +The effective set combines operator-configured members with NIP-11 owner +pubkeys learned for relays referenced by accepted announcements. Purgatory-only +announcements are excluded. Reconciliation reuses the accepted repository index +and the NIP-11 fetch already performed once per connection session, so private +mode adds neither outbound connections nor subscriptions. For Nostr, Hyper completes the public WebSocket upgrade and a message-level proxy sends and validates NIP-42 authentication before a connection reaches @@ -711,6 +716,12 @@ Private mode advertises GRASP-08 plus NIP-42 and NIP-98 in NIP-11. It is incompatible with GRASP-06 because that extension deliberately exposes an unauthenticated contributor write surface. +One process currently represents one private collaborator service. Operators +can run several independently configured instances for different groups. Fleet +provisioning and lifecycle automation are deliberately left to a later change; +they are deployment conveniences rather than part of the authentication +boundary implemented here. + ## Future Extensions ### GRASP-02: Proactive Sync diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2c14a85..ce192b6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1021,7 +1021,7 @@ GRASP-06 contributor endpoint is intentionally unauthenticated. #### `NGIT_PRIVATE_MEMBERS` -**Description:** Service-wide private-service member whitelist +**Description:** Permanently configured members of the service-wide private-service whitelist **Type:** Comma-separated npubs **Default:** Empty **Required:** Yes when `NGIT_PRIVATE_MODE=true` @@ -1030,6 +1030,12 @@ GRASP-06 contributor endpoint is intentionally unauthenticated. NGIT_PRIVATE_MEMBERS=npub1alice...,npub1bob... ``` +The effective GRASP-08 whitelist is the union of these configured members and +the NIP-11 owner pubkeys of relays referenced by accepted repository +announcements. Relay owners are learned by the NIP-11 request already made for +sync limits; this does not open another connection or subscription. A relay +listed only by an unpromoted purgatory announcement cannot grant access. + #### `NGIT_PRIVATE_PUBLIC_ORIGIN` Optional canonical external HTTP origin for private Git authentication, such diff --git a/nix/module.nix b/nix/module.nix index 7bbd3ba..ed4712c 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -406,9 +406,11 @@ let default = [ ]; example = [ "npub1alice..." "npub1bob..." ]; description = '' - Service-wide GRASP-08 member whitelist. At least one valid npub is - required when privateMode is enabled. Malformed entries make the - service fail at startup. + Permanently configured GRASP-08 service members. The effective + whitelist also includes NIP-11 owners of relays referenced by + accepted repository announcements. At least one valid configured + npub is required when privateMode is enabled; malformed entries make + the service fail at startup. ''; }; diff --git a/src/config.rs b/src/config.rs index 15d2337..358c341 100644 --- a/src/config.rs +++ b/src/config.rs @@ -599,9 +599,11 @@ pub struct Config { #[arg(long, env = "NGIT_PRIVATE_MODE", default_value_t = false)] pub private_mode: bool, - /// Service-wide GRASP-08 member whitelist as comma-separated npubs. + /// Permanently configured GRASP-08 members as comma-separated npubs. /// - /// Required and fail-closed when private mode is enabled. + /// The effective whitelist also includes NIP-11 owners of relays referenced + /// by accepted repository announcements. Required and fail-closed when + /// private mode is enabled. #[arg(long, env = "NGIT_PRIVATE_MEMBERS", default_value = "")] pub private_members: String, diff --git a/src/private/access.rs b/src/private/access.rs index 89a0ef5..7b9379d 100644 --- a/src/private/access.rs +++ b/src/private/access.rs @@ -51,14 +51,21 @@ impl PrivateAccess { } /// Atomically replace membership and notify active sessions. - pub fn replace(&self, members: impl IntoIterator) { - *self + pub fn replace(&self, members: impl IntoIterator) -> bool { + let members = members.into_iter().collect(); + let mut current = self .inner .members .write() - .expect("private access lock poisoned") = members.into_iter().collect(); + .expect("private access lock poisoned"); + if *current == members { + return false; + } + *current = members; + drop(current); let next = self.inner.generation.borrow().wrapping_add(1); self.inner.generation.send_replace(next); + true } pub fn subscribe(&self) -> watch::Receiver { @@ -81,9 +88,12 @@ mod tests { assert!(access.contains(&first)); assert!(!access.contains(&second)); - access.replace([second]); + assert!(access.replace([second])); assert!(!access.contains(&first)); assert!(access.contains(&second)); assert_eq!(*generation.borrow(), 1); + + assert!(!access.replace([second])); + assert_eq!(*generation.borrow(), 1); } } diff --git a/src/server.rs b/src/server.rs index 1cd8382..d20cb98 100644 --- a/src/server.rs +++ b/src/server.rs @@ -233,6 +233,7 @@ impl RelayServer { &config, PathBuf::from(config.effective_git_data_path()), metrics.as_ref().and_then(|m| m.sync_metrics().cloned()), + private_access.clone(), ); if config.sync_bootstrap_relay_url.is_some() { diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 8033703..761dafd 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -57,6 +57,7 @@ use crate::nostr::SharedDatabase; use crate::outbound::{ url_matches_service_domain, OutboundTargetKind, OutboundTargetPolicy, RelayTargetSource, }; +use crate::private::PrivateAccess; use nostr_sdk::prelude::LocalRelay; const MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK: usize = 32; @@ -207,6 +208,22 @@ fn dependency_relay_retention() -> Duration { } } +fn effective_private_members( + configured: &HashSet, + accepted_relays: &HashSet, + relay_owners: &HashMap, +) -> HashSet { + configured + .iter() + .copied() + .chain( + relay_owners + .iter() + .filter_map(|(relay, owner)| accepted_relays.contains(relay).then_some(*owner)), + ) + .collect() +} + fn byte_limited_catchup_interval() -> Duration { if std::env::var("NGIT_TEST").as_deref() == Ok("1") { Duration::from_secs(2) @@ -1767,6 +1784,7 @@ enum ConnectAttemptOutcome { Connected { advertised_default_limit: Option, advertised_max_subscriptions: Option, + advertised_owner: Option, }, Failed(String), } @@ -2201,6 +2219,7 @@ async fn run_purgatory_announcement_sync( _ = tokio::time::sleep(interval) => { let mut manager = sync_manager.lock().await; manager.sync_purgatory_announcements_to_index().await; + manager.reconcile_private_membership().await; manager.tick_missing_event_recovery().await; manager.tick_descendant_sync().await; } @@ -2443,6 +2462,12 @@ pub struct SyncManager { repo_sync_index: RepoSyncIndex, root_candidate_index: RootCandidateIndex, proactive_participant_authors: crate::nostr::policy::SharedProactiveParticipantAuthorIndex, + /// GRASP-08 access shared with the inbound HTTP/WebSocket boundary. + private_access: Option, + /// Operator-configured members form the permanent base of private access. + configured_private_members: HashSet, + /// Latest NIP-11 owner learned for each connected repository relay. + relay_owners: HashMap, /// What we've confirmed syncing + connection state relay_sync_index: RelaySyncIndex, /// In-flight subscription batches @@ -2544,6 +2569,7 @@ impl SyncManager { config: &Config, data_path: PathBuf, sync_metrics: Option, + private_access: Option, ) -> Self { // Extract purgatory from write_policy for read-only access let purgatory = write_policy.purgatory().clone(); @@ -2579,6 +2605,11 @@ impl SyncManager { } let proactive_participant_authors = write_policy.proactive_participant_authors(); + let configured_private_members = config + .parse_private_members() + .expect("private members were validated before SyncManager construction") + .into_iter() + .collect(); Self { bootstrap_relay_url, service_domain, @@ -2590,6 +2621,9 @@ impl SyncManager { repo_sync_index: Arc::new(RwLock::new(HashMap::new())), root_candidate_index: Arc::new(RwLock::new(HashMap::new())), proactive_participant_authors, + private_access, + configured_private_members, + relay_owners: HashMap::new(), relay_sync_index: Arc::new(RwLock::new(HashMap::new())), pending_sync_index: Arc::new(RwLock::new(HashMap::new())), rejected_events_index, @@ -5431,6 +5465,7 @@ impl SyncManager { ConnectAttemptOutcome::Connected { advertised_default_limit: hints.default_limit, advertised_max_subscriptions: hints.max_subscriptions, + advertised_owner: hints.owner, } }, Err(error) => ConnectAttemptOutcome::Failed(error), @@ -5461,6 +5496,38 @@ impl SyncManager { targets } + /// Rebuild GRASP-08 membership from accepted-announcement sync state and + /// the latest NIP-11 owner learned for each referenced relay. + /// + /// Purgatory announcements are deliberately excluded: they have not yet + /// passed repository admission and therefore cannot grant service access. + async fn reconcile_private_membership(&self) { + let Some(access) = &self.private_access else { + return; + }; + let repo_index = self.repo_sync_index.read().await; + let accepted_relays: HashSet = repo_index + .values() + .filter(|needs| needs.sync_level == SyncLevel::Full) + .flat_map(|needs| needs.relays.iter()) + .filter_map(|relay| canonical_relay_key(relay).ok()) + .collect(); + drop(repo_index); + let members = effective_private_members( + &self.configured_private_members, + &accepted_relays, + &self.relay_owners, + ); + if access.replace(members) { + tracing::info!( + configured_members = self.configured_private_members.len(), + accepted_relay_count = accepted_relays.len(), + effective_members = access.len(), + "Reconciled GRASP-08 service membership" + ); + } + } + fn configured_nip65_fallback_relays(&self) -> HashSet { self.config .parse_sync_plus_fallback_relays() @@ -6185,7 +6252,17 @@ impl SyncManager { ConnectAttemptOutcome::Connected { advertised_default_limit, advertised_max_subscriptions, + advertised_owner, } => { + match advertised_owner { + Some(owner) => { + self.relay_owners.insert(result.relay_url.clone(), owner); + } + None => { + self.relay_owners.remove(&result.relay_url); + } + } + self.reconcile_private_membership().await; if let Some(connection) = self.connections.get(&result.relay_url) { connection.reset_subscription_budget(advertised_max_subscriptions); } @@ -8915,6 +8992,23 @@ mod tests { assert!(!rejected.contains(&child.id)); } + #[test] + fn private_members_include_only_owners_of_accepted_relays() { + let configured = Keys::generate().public_key(); + let accepted_owner = Keys::generate().public_key(); + let unrelated_owner = Keys::generate().public_key(); + let members = effective_private_members( + &HashSet::from([configured]), + &HashSet::from(["wss://accepted.example/".to_string()]), + &HashMap::from([ + ("wss://accepted.example/".to_string(), accepted_owner), + ("wss://unrelated.example/".to_string(), unrelated_owner), + ]), + ); + + assert_eq!(members, HashSet::from([configured, accepted_owner])); + } + #[test] fn partial_nip65_batch_retries_missing_authors_early() { let returned = Keys::generate().public_key(); diff --git a/src/sync/relay_connection.rs b/src/sync/relay_connection.rs index f7d152d..98d4930 100644 --- a/src/sync/relay_connection.rs +++ b/src/sync/relay_connection.rs @@ -438,6 +438,28 @@ type LiveReqPermitMap = std::sync::Arc< pub struct RelayLimitHints { pub default_limit: Option, pub max_subscriptions: Option, + /// Relay operator identity advertised by NIP-11. Private GRASP services + /// grant this identity access only when the relay is referenced by an + /// accepted repository announcement. + pub owner: Option, +} + +fn parse_relay_limit_hints(body: &str) -> RelayLimitHints { + let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else { + return RelayLimitHints::default(); + }; + let limitation = document.limitation.unwrap_or_default(); + RelayLimitHints { + default_limit: limitation + .default_limit + .and_then(|limit| usize::try_from(limit).ok()) + .filter(|limit| *limit > 0), + max_subscriptions: limitation + .max_subscriptions + .and_then(|limit| usize::try_from(limit).ok()) + .filter(|limit| *limit > 0), + owner: document.pubkey, + } } /// How a failed negentropy diff should affect future NIP-77 attempts. @@ -941,23 +963,7 @@ impl RelayConnection { return RelayLimitHints::default(); } }; - let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() - else { - return RelayLimitHints::default(); - }; - let Some(limitation) = document.limitation else { - return RelayLimitHints::default(); - }; - RelayLimitHints { - default_limit: limitation - .default_limit - .and_then(|limit| usize::try_from(limit).ok()) - .filter(|limit| *limit > 0), - max_subscriptions: limitation - .max_subscriptions - .and_then(|limit| usize::try_from(limit).ok()) - .filter(|limit| *limit > 0), - } + parse_relay_limit_hints(&body) } /// Whether the SDK still considers this relay's WebSocket established. @@ -2745,6 +2751,18 @@ impl RelayConnection { mod tests { use super::*; + #[test] + fn nip11_owner_is_retained_without_a_limitation_object() { + let owner = Keys::generate().public_key(); + let body = format!(r#"{{"pubkey":"{}"}}"#, owner.to_hex()); + + let hints = parse_relay_limit_hints(&body); + + assert_eq!(hints.owner, Some(owner)); + assert_eq!(hints.default_limit, None); + assert_eq!(hints.max_subscriptions, None); + } + /// Event-directed connection with the permissive policy used by tests /// that dial loopback fixtures. fn permissive_connection(url: &str, keys: Keys) -> RelayConnection {