mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
+13
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user