Files
ngit-grasp/tests/private_mode.rs
T
DanConwayDev 7dc786827f 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.
2026-08-15 13:43:22 +00:00

677 lines
23 KiB
Rust

mod common;
use std::time::Duration;
use base64::Engine;
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};
use tokio_tungstenite::tungstenite::Message;
type WsStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
const WS_DEADLINE: Duration = Duration::from_secs(10);
fn credential(keys: &Keys, repository_url: &str) -> String {
credential_at(keys, repository_url, Timestamp::now())
}
fn credential_at(keys: &Keys, repository_url: &str, created_at: Timestamp) -> String {
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(vec![
Tag::parse(["u", repository_url]).expect("URL tag"),
Tag::parse(["method", "GET"]).expect("method tag"),
])
.custom_created_at(created_at)
.finalize(keys)
.expect("signed NIP-98 credential");
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(event.as_json())
)
}
fn auth_message(keys: &Keys, relay_domain: &str, challenge: &str) -> String {
let relay_url = format!("ws://{relay_domain}");
let event = EventBuilder::new(Kind::Authentication, "")
.tags(vec![
Tag::parse(["relay", relay_url.as_str()]).expect("relay tag"),
Tag::parse(["challenge", challenge]).expect("challenge tag"),
])
.finalize(keys)
.expect("signed NIP-42 event");
format!("[\"AUTH\",{}]", event.as_json())
}
/// Await the next text frame within the bounded deadline, skipping
/// non-text control frames.
async fn next_text(stream: &mut WsStream) -> String {
tokio::time::timeout(WS_DEADLINE, async {
while let Some(message) = stream.next().await {
if let Message::Text(text) = message.expect("valid websocket frame") {
return text.to_string();
}
}
panic!("websocket closed while awaiting a relay message");
})
.await
.expect("relay message within deadline")
}
/// Connect to a private relay and consume the initial NIP-42 challenge.
async fn connect_and_challenge(relay: &TestRelay) -> (WsStream, String) {
let (mut stream, _) = tokio_tungstenite::connect_async(relay.url())
.await
.expect("connect to private relay");
let frame: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON message");
assert_eq!(
frame[0], "AUTH",
"first relay message must be the challenge"
);
let challenge = frame[1].as_str().expect("challenge string").to_owned();
(stream, challenge)
}
async fn send_text(stream: &mut WsStream, text: String) {
tokio::time::timeout(WS_DEADLINE, stream.send(Message::Text(text.into())))
.await
.expect("send within deadline")
.expect("send websocket frame");
}
/// Assert the peer terminates the connection without further relay messages.
async fn expect_closed(stream: &mut WsStream) {
tokio::time::timeout(WS_DEADLINE, async {
loop {
match stream.next().await {
None | Some(Ok(Message::Close(_))) | Some(Err(_)) => return,
Some(Ok(Message::Text(text))) => {
panic!("expected connection close, received: {text}")
}
Some(Ok(_)) => {}
}
}
})
.await
.expect("connection close within deadline");
}
#[tokio::test]
async fn private_git_endpoint_requires_a_service_member() {
let member = Keys::generate();
let outsider = Keys::generate();
let repository_owner = Keys::generate()
.public_key()
.to_bech32()
.expect("repository owner npub");
let relay = TestRelay::start_private(&member.public_key()).await;
let repository_url = format!(
"http://{}/{repository_owner}/private-repository.git",
relay.domain()
);
let client = reqwest::Client::new();
for authorization in [None, Some(credential(&outsider, &repository_url))] {
let mut request = client.get(&repository_url);
if let Some(authorization) = authorization {
request = request.header(AUTHORIZATION, authorization);
}
let response = request.send().await.expect("private Git response");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(
response
.headers()
.get(WWW_AUTHENTICATE)
.expect("Nostr challenge")
.to_str()
.expect("ASCII challenge"),
format!("Nostr realm=\"{}\", method=\"GET\"", relay.domain())
);
assert!(response.bytes().await.expect("response body").is_empty());
}
let response = client
.get(&repository_url)
.header(AUTHORIZATION, credential(&member, &repository_url))
.send()
.await
.expect("authenticated Git response");
assert_ne!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
relay.stop().await;
}
#[tokio::test]
async fn private_git_credential_expiry_and_smart_http_scope() {
let member = Keys::generate();
let repository_owner = Keys::generate()
.public_key()
.to_bech32()
.expect("repository owner npub");
let relay = TestRelay::start_private(&member.public_key()).await;
let repository_url = format!(
"http://{}/{repository_owner}/private-repository.git",
relay.domain()
);
let info_refs_url = format!("{repository_url}/info/refs?service=git-upload-pack");
let client = reqwest::Client::new();
// A credential outside the 60-second validity window is indistinguishable
// from any other failure: same empty 401 challenge.
let expired = credential_at(
&member,
&repository_url,
Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(300)),
);
let response = client
.get(&info_refs_url)
.header(AUTHORIZATION, expired)
.send()
.await
.expect("expired credential response");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
assert!(response.bytes().await.expect("response body").is_empty());
// A credential signing the Smart HTTP subpath instead of the repository
// root does not match the GRASP-08 canonical URL.
let response = client
.get(&info_refs_url)
.header(AUTHORIZATION, credential(&member, &info_refs_url))
.send()
.await
.expect("subpath-scoped credential response");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
// The repository-root credential is reusable across Smart HTTP endpoints
// within its validity window.
let reusable = credential(&member, &repository_url);
for url in [&info_refs_url, &repository_url] {
let response = client
.get(url)
.header(AUTHORIZATION, reusable.clone())
.send()
.await
.expect("member Smart HTTP response");
assert_ne!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
relay.stop().await;
}
#[tokio::test]
async fn private_nip11_document_stays_public_and_advertises_auth() {
let member = Keys::generate();
let relay = TestRelay::start_private(&member.public_key()).await;
// Clients must be able to discover the authentication requirement without
// credentials, so the NIP-11 document is deliberately unauthenticated.
let response = reqwest::Client::new()
.get(format!("http://{}/", relay.domain()))
.header(ACCEPT, "application/nostr+json")
.send()
.await
.expect("NIP-11 response");
assert_eq!(response.status(), reqwest::StatusCode::OK);
let document: serde_json::Value = response.json().await.expect("NIP-11 JSON");
let nips = document["supported_nips"]
.as_array()
.expect("supported_nips array");
for nip in [42, 98] {
assert!(
nips.contains(&serde_json::Value::from(nip)),
"NIP-11 must advertise NIP-{nip}: {nips:?}"
);
}
relay.stop().await;
}
#[tokio::test]
async fn private_websocket_rejects_messages_before_authentication() {
let member = Keys::generate();
let relay = TestRelay::start_private(&member.public_key()).await;
let (mut stream, _challenge) = connect_and_challenge(&relay).await;
send_text(&mut stream, r#"["REQ","pre-auth",{}]"#.to_string()).await;
let closed: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("CLOSED JSON");
assert_eq!(closed[0], "CLOSED");
assert_eq!(closed[1], "pre-auth");
assert!(
closed[2]
.as_str()
.expect("CLOSED message")
.starts_with("auth-required:"),
"{closed}"
);
let note = EventBuilder::new(Kind::TextNote, "pre-auth publish")
.finalize(&member)
.expect("signed note");
send_text(&mut stream, format!("[\"EVENT\",{}]", note.as_json())).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[1].as_str(), Some(note.id.to_hex().as_str()));
assert_eq!(ok[2], false);
assert!(
ok[3]
.as_str()
.expect("OK message")
.starts_with("auth-required:"),
"{ok}"
);
send_text(&mut stream, "not a nostr message".to_string()).await;
let notice: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("NOTICE JSON");
assert_eq!(notice[0], "NOTICE");
relay.stop().await;
}
#[tokio::test]
async fn private_websocket_admits_member_and_bridges_to_relay() {
let member = 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[0], "OK");
assert_eq!(
ok[2], true,
"member NIP-42 authentication must succeed: {ok}"
);
// The authenticated session reaches the inner relay: a subscription is
// answered with EOSE instead of an auth-required rejection.
send_text(
&mut stream,
r#"["REQ","after-auth",{"kinds":[1],"limit":1}]"#.to_string(),
)
.await;
let deadline = tokio::time::timeout(WS_DEADLINE, async {
loop {
let frame: serde_json::Value =
serde_json::from_str(&next_text(&mut stream).await).expect("relay JSON");
if frame[0] == "EOSE" && frame[1] == "after-auth" {
return;
}
assert_ne!(
frame[0], "CLOSED",
"authenticated REQ was rejected: {frame}"
);
}
})
.await;
assert!(deadline.is_ok(), "no EOSE for authenticated subscription");
relay.stop().await;
}
#[tokio::test]
async fn private_websocket_rejects_valid_nonmember_auth_and_closes() {
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(&outsider, &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);
assert!(
ok[3]
.as_str()
.expect("OK message")
.starts_with("restricted:"),
"valid non-member auth must be restricted: {ok}"
);
expect_closed(&mut stream).await;
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;
}
/// 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();
let outsider = Keys::generate();
let relay = TestRelay::start_private(&member.public_key()).await;
let (mut stream, _challenge) = connect_and_challenge(&relay).await;
// Three syntactically valid AUTH events signed over the wrong challenge
// exhaust the attempt budget and terminate the connection.
for _ in 0..3 {
send_text(
&mut stream,
auth_message(&outsider, &relay.domain(), "wrong-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);
assert!(
ok[3]
.as_str()
.expect("OK message")
.starts_with("auth-required:"),
"{ok}"
);
}
expect_closed(&mut stream).await;
relay.stop().await;
}