From 7dc786827f3f3cca90d38de2f3dff8e6d49d2253 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sat, 15 Aug 2026 13:43:22 +0000 Subject: [PATCH] feat(private-repos): require GRASP-08 advertisement for derived membership Derived private membership previously admitted the NIP-11 owner of any relay referenced by an accepted announcement. A public relay's owner gains nothing legitimate from private membership - their relay enforces no confidentiality for the repositories it mirrors - so minting access for them needlessly widens the trust domain. Approach: parse the GRASP `supported_grasps` extension array from the raw NIP-11 body (the SDK type does not carry it) into a new `RelayLimitHints::grasp08` flag, thread it through the connect-attempt outcome, and only insert into `relay_owners` when the relay advertises "GRASP-08". Absent or malformed documents mean "not a private service". Operator-configured NGIT_PRIVATE_MEMBERS are unaffected. Correctness assumptions: `relay_owners` is the sole source of derived members (`effective_private_members`), so gating insertion gates the whole derivation; removing a stale entry on a non-advertising session keeps a relay that stops advertising GRASP-08 from retaining minted membership past its next reconnect. Test infrastructure: MockRelay can now serve a caller-provided NIP-11 document, `push_to_relay` gained an Authorization-header variant for pushes to private services, and TestRelay gained a persistent-LMDB private constructor. The integration test restarts the relay after promotion because the private NIP-42 gate also applies to the internal self-subscription, so locally published announcements only reach the sync manager through the startup database load; that pre-existing limitation is out of scope here. Validation: new unit tests for the supported_grasps parse; integration test proves a GRASP-08-advertising relay's owner is admitted while an otherwise identical non-advertising relay's owner stays restricted. cargo test --lib grasp08_flag and --test private_mode derived_membership_requires_grasp08_advertising_relay pass. --- src/sync/mod.rs | 15 ++- src/sync/relay_connection.rs | 31 +++++ tests/common/mock_relay.rs | 51 ++++++- tests/common/purgatory_helpers.rs | 23 +++- tests/common/relay.rs | 30 +++++ tests/private_mode.rs | 217 +++++++++++++++++++++++++++++- 6 files changed, 361 insertions(+), 6 deletions(-) diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 8e94f74..0f27c8b 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1785,6 +1785,7 @@ enum ConnectAttemptOutcome { advertised_default_limit: Option, advertised_max_subscriptions: Option, advertised_owner: Option, + advertised_grasp08: bool, }, Failed(String), } @@ -2466,7 +2467,11 @@ pub struct SyncManager { 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. + /// Latest NIP-11 owner learned for each connected repository relay whose + /// NIP-11 also advertises GRASP-08. Only private-service owners can mint + /// derived membership: the owner of a public relay gains nothing + /// legitimate from private membership, since their relay enforces no + /// confidentiality for the repositories it mirrors. relay_owners: HashMap, /// What we've confirmed syncing + connection state relay_sync_index: RelaySyncIndex, @@ -5466,6 +5471,7 @@ impl SyncManager { advertised_default_limit: hints.default_limit, advertised_max_subscriptions: hints.max_subscriptions, advertised_owner: hints.owner, + advertised_grasp08: hints.grasp08, } }, Err(error) => ConnectAttemptOutcome::Failed(error), @@ -6253,8 +6259,13 @@ impl SyncManager { advertised_default_limit, advertised_max_subscriptions, advertised_owner, + advertised_grasp08, } => { - match advertised_owner { + // Only GRASP-08-advertising relays mint derived private + // membership: a public relay's owner gains nothing legitimate + // from private membership because their relay enforces no + // confidentiality for the mirrored repositories. + match advertised_owner.filter(|_| advertised_grasp08) { Some(owner) => { self.relay_owners.insert(result.relay_url.clone(), owner); } diff --git a/src/sync/relay_connection.rs b/src/sync/relay_connection.rs index 98d4930..4c81879 100644 --- a/src/sync/relay_connection.rs +++ b/src/sync/relay_connection.rs @@ -442,12 +442,26 @@ pub struct RelayLimitHints { /// grant this identity access only when the relay is referenced by an /// accepted repository announcement. pub owner: Option, + /// Whether the document's `supported_grasps` array (a GRASP extension + /// field, parsed from the raw JSON because the SDK's NIP-11 type does not + /// carry it) advertises the "GRASP-08" private-service extension. + pub grasp08: bool, } fn parse_relay_limit_hints(body: &str) -> RelayLimitHints { let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else { return RelayLimitHints::default(); }; + let grasp08 = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value.get("supported_grasps")?.as_array().map(|grasps| { + grasps + .iter() + .any(|grasp| grasp.as_str() == Some("GRASP-08")) + }) + }) + .unwrap_or(false); let limitation = document.limitation.unwrap_or_default(); RelayLimitHints { default_limit: limitation @@ -459,6 +473,7 @@ fn parse_relay_limit_hints(body: &str) -> RelayLimitHints { .and_then(|limit| usize::try_from(limit).ok()) .filter(|limit| *limit > 0), owner: document.pubkey, + grasp08, } } @@ -2763,6 +2778,22 @@ mod tests { assert_eq!(hints.max_subscriptions, None); } + #[test] + fn grasp08_flag_requires_supported_grasps_entry() { + assert!(parse_relay_limit_hints(r#"{"supported_grasps":["GRASP-01","GRASP-08"]}"#).grasp08); + assert!(!parse_relay_limit_hints(r#"{"supported_grasps":["GRASP-01"]}"#).grasp08); + assert!(!parse_relay_limit_hints(r#"{"name":"relay without grasps"}"#).grasp08); + } + + #[test] + fn grasp08_flag_defaults_to_false_for_malformed_documents() { + // Non-array supported_grasps and unparseable bodies both mean "not a + // known private service", never an error. + assert!(!parse_relay_limit_hints(r#"{"supported_grasps":"GRASP-08"}"#).grasp08); + assert!(!parse_relay_limit_hints(r#"{"supported_grasps":8}"#).grasp08); + assert!(!parse_relay_limit_hints("not json at all").grasp08); + } + /// Event-directed connection with the permissive policy used by tests /// that dial loopback fixtures. fn permissive_connection(url: &str, keys: Keys) -> RelayConnection { diff --git a/tests/common/mock_relay.rs b/tests/common/mock_relay.rs index 43befa1..ebbed89 100644 --- a/tests/common/mock_relay.rs +++ b/tests/common/mock_relay.rs @@ -122,6 +122,24 @@ impl MockRelay { rate_limit: RateLimit, pagination: Option, max_filters: Option, + ) -> Self { + Self::start_with_options_and_nip11(rate_limit, pagination, max_filters, None).await + } + + /// Start a mock relay that serves a caller-provided NIP-11 document for + /// `Accept: application/nostr+json` requests. + /// + /// This lets tests control fields the default document never emits, such + /// as the GRASP `supported_grasps` extension array or the `pubkey` owner. + pub async fn start_with_nip11_document(document: serde_json::Value) -> Self { + Self::start_with_options_and_nip11(RateLimit::default(), None, None, Some(document)).await + } + + async fn start_with_options_and_nip11( + rate_limit: RateLimit, + pagination: Option, + max_filters: Option, + custom_nip11: Option, ) -> Self { // Create and bind listener (eliminates port race condition) let std_listener = @@ -145,6 +163,7 @@ impl MockRelay { pagination, max_filters, Vec::new(), + custom_nip11, ) .await } @@ -164,10 +183,20 @@ impl MockRelay { let listener = TcpListener::bind(addr) .await .expect("Failed to bind to address"); - Self::start_with_listener(listener, port, RateLimit::default(), None, None, events).await + Self::start_with_listener( + listener, + port, + RateLimit::default(), + None, + None, + events, + None, + ) + .await } /// Internal method to start the relay with an existing listener. + #[allow(clippy::too_many_arguments)] async fn start_with_listener( listener: TcpListener, port: u16, @@ -175,6 +204,7 @@ impl MockRelay { pagination: Option, max_filters: Option, initial_events: Vec, + custom_nip11: Option, ) -> Self { // Create a simple relay with no write policy (accepts all events) let mut builder = LocalRelayBuilder::default().rate_limit(rate_limit); @@ -209,13 +239,22 @@ impl MockRelay { Ok((stream, remote_addr)) => { let relay = server_relay.clone(); let pagination = pagination; + let custom_nip11 = custom_nip11.clone(); let io = TokioIo::new(stream); tokio::spawn(async move { let service = service_fn(move |req| { let relay = relay.clone(); + let custom_nip11 = custom_nip11.clone(); async move { - handle_request(req, relay, remote_addr, pagination).await + handle_request( + req, + relay, + remote_addr, + pagination, + custom_nip11, + ) + .await } }); @@ -302,6 +341,7 @@ async fn handle_request( relay: LocalRelay, addr: SocketAddr, pagination: Option, + custom_nip11: Option, ) -> Result>, hyper::Error> { // Check for WebSocket upgrade request let is_websocket = req @@ -350,6 +390,13 @@ async fn handle_request( .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.contains("application/nostr+json")) { + if let Some(document) = custom_nip11 { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/nostr+json") + .body(Full::new(Bytes::from(document.to_string()))) + .unwrap()); + } let limitation = pagination.map(|config| { serde_json::json!({ "default_limit": config.advertised_default_limit, diff --git a/tests/common/purgatory_helpers.rs b/tests/common/purgatory_helpers.rs index 07fa844..b9dd77f 100644 --- a/tests/common/purgatory_helpers.rs +++ b/tests/common/purgatory_helpers.rs @@ -587,6 +587,21 @@ pub fn push_to_relay( relay_domain: &str, npub: &str, repo_id: &str, +) -> Result<(), String> { + push_to_relay_with_auth_header(local_path, relay_domain, npub, repo_id, None) +} + +/// Push a local repository to a relay with an optional `Authorization` header. +/// +/// GRASP-08 private relays require every Smart HTTP request to carry the +/// repository-scoped NIP-98 credential, which git can attach via +/// `http.extraHeader`. Pass the full header value (e.g. `Nostr `). +pub fn push_to_relay_with_auth_header( + local_path: &Path, + relay_domain: &str, + npub: &str, + repo_id: &str, + auth_header: Option<&str>, ) -> Result<(), String> { let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); @@ -606,7 +621,13 @@ pub fn push_to_relay( } // Push all refs - let output = git_command() + let mut command = git_command(); + if let Some(header) = auth_header { + command + .arg("-c") + .arg(format!("http.extraHeader=Authorization: {header}")); + } + let output = command .args(["push", "-u", "origin", "--all"]) .current_dir(local_path) .output() diff --git a/tests/common/relay.rs b/tests/common/relay.rs index 3ecfca6..2200876 100644 --- a/tests/common/relay.rs +++ b/tests/common/relay.rs @@ -155,6 +155,36 @@ impl TestRelay { .await } + /// Start a GRASP-08 private service with persistent LMDB storage in + /// caller-owned directories, so [`Self::restart`] resumes from the same + /// events and git data. + /// + /// The private NIP-42 gate applies to the relay's own internal + /// self-subscription, so sync targets for locally published + /// announcements are derived from the database at startup; tests + /// exercising that pipeline publish, then restart. + pub async fn start_private_with_lmdb_paths( + member: &nostr_sdk::prelude::PublicKey, + git_data_path: PathBuf, + relay_data_path: PathBuf, + ) -> Self { + Self::start_internal( + port::reserve_port(), + RelayOptions { + private_members: Some( + member + .to_bech32() + .expect("Failed to encode private test member"), + ), + lmdb_backend: true, + git_data_path: Some(git_data_path), + relay_data_path: Some(relay_data_path), + ..RelayOptions::default() + }, + ) + .await + } + /// Start relay with sync from another relay (bootstrap relay) /// /// # Example diff --git a/tests/private_mode.rs b/tests/private_mode.rs index 0d7fadd..b399797 100644 --- a/tests/private_mode.rs +++ b/tests/private_mode.rs @@ -3,7 +3,10 @@ mod common; use std::time::Duration; use base64::Engine; -use common::TestRelay; +use common::{ + create_state_event, create_test_repo_with_commit, push_to_relay_with_auth_header, + wait_for_sync_connection, CommitVariant, MockRelay, TestClient, TestRelay, +}; use futures_util::{SinkExt, StreamExt}; use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, Timestamp, ToBech32}; use reqwest::header::{ACCEPT, AUTHORIZATION, WWW_AUTHENTICATE}; @@ -428,6 +431,218 @@ async fn private_announcement_admission_requires_member_author() { relay.stop().await; } +/// Poll until `event_id` is served to an authenticated session using `keys`. +async fn wait_for_event_as( + relay_url: &str, + keys: &Keys, + event_id: nostr_sdk::prelude::EventId, + timeout: Duration, +) -> bool { + use nostr_sdk::prelude::{Client, Filter, SignerAuthenticator}; + let deadline = tokio::time::Instant::now() + timeout; + loop { + let client = Client::builder() + .authenticator(SignerAuthenticator::new(keys.clone())) + .build(); + if client.add_relay(relay_url).await.is_ok() { + client.connect().await; + let result = client + .fetch_events(Filter::new().id(event_id)) + .timeout(Duration::from_secs(2)) + .await; + client.disconnect().await; + if let Ok(events) = result { + if !events.is_empty() { + return true; + } + } + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +/// One bounded probe of whether `keys` is currently an effective member: +/// authenticate over a fresh WebSocket session and, when admitted, prove the +/// session is bridged by seeing a REQ answered with EOSE. +async fn membership_admitted(relay: &TestRelay, keys: &Keys) -> bool { + let (mut stream, challenge) = connect_and_challenge(relay).await; + send_text(&mut stream, auth_message(keys, &relay.domain(), &challenge)).await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[0], "OK"); + if ok[2] != true { + return false; + } + send_text( + &mut stream, + r#"["REQ","bridge",{"kinds":[1],"limit":1}]"#.to_string(), + ) + .await; + loop { + let frame: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON"); + if frame[0] == "EOSE" && frame[1] == "bridge" { + return true; + } + assert_ne!( + frame[0], "CLOSED", + "admitted member REQ was rejected: {frame}" + ); + } +} + +#[tokio::test] +async fn derived_membership_requires_grasp08_advertising_relay() { + let member = Keys::generate(); + let owner_plain = Keys::generate(); + let owner_private = Keys::generate(); + let git_data_dir = tempfile::tempdir().expect("git data dir"); + let relay_data_dir = tempfile::tempdir().expect("relay data dir"); + let relay = TestRelay::start_private_with_lmdb_paths( + &member.public_key(), + git_data_dir.path().to_path_buf(), + relay_data_dir.path().to_path_buf(), + ) + .await; + + // Two referenced relays that differ only in whether their NIP-11 + // advertises the GRASP-08 private-service extension. + let plain = MockRelay::start_with_nip11_document(serde_json::json!({ + "name": "public mirror", + "pubkey": owner_plain.public_key().to_hex(), + "supported_nips": [1, 11], + "supported_grasps": ["GRASP-01"], + })) + .await; + let private_peer = MockRelay::start_with_nip11_document(serde_json::json!({ + "name": "private peer", + "pubkey": owner_private.public_key().to_hex(), + "supported_nips": [1, 11], + "supported_grasps": ["GRASP-01", "GRASP-08"], + })) + .await; + + // Member-authored announcement listing our own service (so it is + // admitted) plus both mock relays; the state event and git push promote + // it out of purgatory so it can mint derived membership. + let identifier = "grasp08-derived-membership"; + let npub = member.public_key().to_bech32().expect("member npub"); + let clone_url = format!("http://{}/{npub}/{identifier}.git", relay.domain()); + let relay_urls = vec![ + format!("ws://{}", relay.domain()), + plain.url().to_string(), + private_peer.url().to_string(), + ]; + let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![ + Tag::identifier(identifier), + Tag::custom("clone", vec![clone_url.clone()]), + Tag::custom("relays", relay_urls.clone()), + ]) + .finalize(&member) + .expect("signed announcement"); + + let git_dir = tempfile::tempdir().expect("git temp dir"); + let commit = create_test_repo_with_commit(git_dir.path(), CommitVariant::StateTest) + .expect("test repository"); + let relay_url_refs: Vec<&str> = relay_urls.iter().map(String::as_str).collect(); + let state_event = create_state_event( + &member, + identifier, + &[("main", &commit)], + &[], + &[clone_url.as_str()], + &relay_url_refs, + ) + .expect("state event"); + + // The member passes the inbound NIP-42 gate via the client authenticator + // and the inbound NIP-98 gate via the repository-root credential. + let client = TestClient::new(relay.url(), member.clone()) + .await + .expect("authenticated member client"); + client + .send_event(&announcement) + .await + .expect("announcement admitted"); + client + .send_event(&state_event) + .await + .expect("state event admitted"); + push_to_relay_with_auth_header( + git_dir.path(), + &relay.domain(), + &npub, + identifier, + Some(&credential(&member, &clone_url)), + ) + .expect("authenticated git push"); + client.disconnect().await; + + assert!( + wait_for_event_as( + relay.url(), + &member, + announcement.id, + Duration::from_secs(30) + ) + .await, + "announcement must be promoted out of purgatory" + ); + + // The internal live self-subscription cannot pass the private NIP-42 + // gate, so sync targets for locally promoted announcements are derived + // from the database at startup; restart to exercise that path. + let relay = relay.restart().await; + + wait_for_sync_connection(relay.url(), 2, Duration::from_secs(60)) + .await + .expect("sync connections to both referenced relays"); + + // The GRASP-08-advertising relay's owner becomes an effective member. + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + loop { + if membership_admitted(&relay, &owner_private).await { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "GRASP-08 relay owner was not admitted before the deadline" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + + // The plain relay's owner authenticates validly but is never minted. + let (mut stream, challenge) = connect_and_challenge(&relay).await; + send_text( + &mut stream, + auth_message(&owner_plain, &relay.domain(), &challenge), + ) + .await; + let ok: serde_json::Value = + serde_json::from_str(&next_text(&mut stream).await).expect("OK JSON"); + assert_eq!(ok[0], "OK"); + assert_eq!( + ok[2], false, + "non-GRASP-08 relay owner must not gain membership: {ok}" + ); + assert!( + ok[3] + .as_str() + .expect("OK message") + .starts_with("restricted:"), + "{ok}" + ); + expect_closed(&mut stream).await; + + relay.stop().await; + plain.stop().await; + private_peer.stop().await; +} + #[tokio::test] async fn private_websocket_bounds_invalid_authentication_attempts() { let member = Keys::generate();