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.
This commit is contained in:
DanConwayDev
2026-08-15 13:43:22 +00:00
parent 22abda22a8
commit 7dc786827f
6 changed files with 361 additions and 6 deletions
+13 -2
View File
@@ -1785,6 +1785,7 @@ enum ConnectAttemptOutcome {
advertised_default_limit: Option<usize>, advertised_default_limit: Option<usize>,
advertised_max_subscriptions: Option<usize>, advertised_max_subscriptions: Option<usize>,
advertised_owner: Option<PublicKey>, advertised_owner: Option<PublicKey>,
advertised_grasp08: bool,
}, },
Failed(String), Failed(String),
} }
@@ -2466,7 +2467,11 @@ pub struct SyncManager {
private_access: Option<PrivateAccess>, private_access: Option<PrivateAccess>,
/// Operator-configured members form the permanent base of private access. /// Operator-configured members form the permanent base of private access.
configured_private_members: HashSet<PublicKey>, configured_private_members: HashSet<PublicKey>,
/// 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<String, PublicKey>, relay_owners: HashMap<String, PublicKey>,
/// What we've confirmed syncing + connection state /// What we've confirmed syncing + connection state
relay_sync_index: RelaySyncIndex, relay_sync_index: RelaySyncIndex,
@@ -5466,6 +5471,7 @@ impl SyncManager {
advertised_default_limit: hints.default_limit, advertised_default_limit: hints.default_limit,
advertised_max_subscriptions: hints.max_subscriptions, advertised_max_subscriptions: hints.max_subscriptions,
advertised_owner: hints.owner, advertised_owner: hints.owner,
advertised_grasp08: hints.grasp08,
} }
}, },
Err(error) => ConnectAttemptOutcome::Failed(error), Err(error) => ConnectAttemptOutcome::Failed(error),
@@ -6253,8 +6259,13 @@ impl SyncManager {
advertised_default_limit, advertised_default_limit,
advertised_max_subscriptions, advertised_max_subscriptions,
advertised_owner, 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) => { Some(owner) => {
self.relay_owners.insert(result.relay_url.clone(), owner); self.relay_owners.insert(result.relay_url.clone(), owner);
} }
+31
View File
@@ -442,12 +442,26 @@ pub struct RelayLimitHints {
/// grant this identity access only when the relay is referenced by an /// grant this identity access only when the relay is referenced by an
/// accepted repository announcement. /// accepted repository announcement.
pub owner: Option<PublicKey>, pub owner: Option<PublicKey>,
/// 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 { fn parse_relay_limit_hints(body: &str) -> RelayLimitHints {
let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else { let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else {
return RelayLimitHints::default(); return RelayLimitHints::default();
}; };
let grasp08 = serde_json::from_str::<serde_json::Value>(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(); let limitation = document.limitation.unwrap_or_default();
RelayLimitHints { RelayLimitHints {
default_limit: limitation default_limit: limitation
@@ -459,6 +473,7 @@ fn parse_relay_limit_hints(body: &str) -> RelayLimitHints {
.and_then(|limit| usize::try_from(limit).ok()) .and_then(|limit| usize::try_from(limit).ok())
.filter(|limit| *limit > 0), .filter(|limit| *limit > 0),
owner: document.pubkey, owner: document.pubkey,
grasp08,
} }
} }
@@ -2763,6 +2778,22 @@ mod tests {
assert_eq!(hints.max_subscriptions, None); 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 /// Event-directed connection with the permissive policy used by tests
/// that dial loopback fixtures. /// that dial loopback fixtures.
fn permissive_connection(url: &str, keys: Keys) -> RelayConnection { fn permissive_connection(url: &str, keys: Keys) -> RelayConnection {
+49 -2
View File
@@ -122,6 +122,24 @@ impl MockRelay {
rate_limit: RateLimit, rate_limit: RateLimit,
pagination: Option<PaginationConfig>, pagination: Option<PaginationConfig>,
max_filters: Option<usize>, max_filters: Option<usize>,
) -> 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<PaginationConfig>,
max_filters: Option<usize>,
custom_nip11: Option<serde_json::Value>,
) -> Self { ) -> Self {
// Create and bind listener (eliminates port race condition) // Create and bind listener (eliminates port race condition)
let std_listener = let std_listener =
@@ -145,6 +163,7 @@ impl MockRelay {
pagination, pagination,
max_filters, max_filters,
Vec::new(), Vec::new(),
custom_nip11,
) )
.await .await
} }
@@ -164,10 +183,20 @@ impl MockRelay {
let listener = TcpListener::bind(addr) let listener = TcpListener::bind(addr)
.await .await
.expect("Failed to bind to address"); .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. /// Internal method to start the relay with an existing listener.
#[allow(clippy::too_many_arguments)]
async fn start_with_listener( async fn start_with_listener(
listener: TcpListener, listener: TcpListener,
port: u16, port: u16,
@@ -175,6 +204,7 @@ impl MockRelay {
pagination: Option<PaginationConfig>, pagination: Option<PaginationConfig>,
max_filters: Option<usize>, max_filters: Option<usize>,
initial_events: Vec<Event>, initial_events: Vec<Event>,
custom_nip11: Option<serde_json::Value>,
) -> Self { ) -> Self {
// Create a simple relay with no write policy (accepts all events) // Create a simple relay with no write policy (accepts all events)
let mut builder = LocalRelayBuilder::default().rate_limit(rate_limit); let mut builder = LocalRelayBuilder::default().rate_limit(rate_limit);
@@ -209,13 +239,22 @@ impl MockRelay {
Ok((stream, remote_addr)) => { Ok((stream, remote_addr)) => {
let relay = server_relay.clone(); let relay = server_relay.clone();
let pagination = pagination; let pagination = pagination;
let custom_nip11 = custom_nip11.clone();
let io = TokioIo::new(stream); let io = TokioIo::new(stream);
tokio::spawn(async move { tokio::spawn(async move {
let service = service_fn(move |req| { let service = service_fn(move |req| {
let relay = relay.clone(); let relay = relay.clone();
let custom_nip11 = custom_nip11.clone();
async move { 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, relay: LocalRelay,
addr: SocketAddr, addr: SocketAddr,
pagination: Option<PaginationConfig>, pagination: Option<PaginationConfig>,
custom_nip11: Option<serde_json::Value>,
) -> Result<Response<Full<Bytes>>, hyper::Error> { ) -> Result<Response<Full<Bytes>>, hyper::Error> {
// Check for WebSocket upgrade request // Check for WebSocket upgrade request
let is_websocket = req let is_websocket = req
@@ -350,6 +390,13 @@ async fn handle_request(
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("application/nostr+json")) .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| { let limitation = pagination.map(|config| {
serde_json::json!({ serde_json::json!({
"default_limit": config.advertised_default_limit, "default_limit": config.advertised_default_limit,
+22 -1
View File
@@ -587,6 +587,21 @@ pub fn push_to_relay(
relay_domain: &str, relay_domain: &str,
npub: &str, npub: &str,
repo_id: &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 <base64>`).
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> { ) -> Result<(), String> {
let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); let remote_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
@@ -606,7 +621,13 @@ pub fn push_to_relay(
} }
// Push all refs // 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"]) .args(["push", "-u", "origin", "--all"])
.current_dir(local_path) .current_dir(local_path)
.output() .output()
+30
View File
@@ -155,6 +155,36 @@ impl TestRelay {
.await .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) /// Start relay with sync from another relay (bootstrap relay)
/// ///
/// # Example /// # Example
+216 -1
View File
@@ -3,7 +3,10 @@ mod common;
use std::time::Duration; use std::time::Duration;
use base64::Engine; 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 futures_util::{SinkExt, StreamExt};
use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, Timestamp, ToBech32}; use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, Timestamp, ToBech32};
use reqwest::header::{ACCEPT, AUTHORIZATION, WWW_AUTHENTICATE}; use reqwest::header::{ACCEPT, AUTHORIZATION, WWW_AUTHENTICATE};
@@ -428,6 +431,218 @@ async fn private_announcement_admission_requires_member_author() {
relay.stop().await; 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] #[tokio::test]
async fn private_websocket_bounds_invalid_authentication_attempts() { async fn private_websocket_bounds_invalid_authentication_attempts() {
let member = Keys::generate(); let member = Keys::generate();