mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
feat(private-repos): gate announcement admission on membership
In GRASP-08 private mode, accepted kind-30617 announcements expand hosting and — via their referenced relays' NIP-11 owners — the effective member set itself. Admission never checked the announcement author, so a member could submit a third-party-signed announcement (or sync could import one from an operator-configured source) and mint membership for pubkeys no member ever chose. AnnouncementPolicy now rejects a repository announcement whose author is not a current effective member, using the shared PrivateAccess set (configured members plus admitted relay owners) threaded from server startup through create_relay and Nip34WritePolicy. The check runs at the top of AnnouncementPolicy::validate, the single choke point every announcement arrival path (direct publish, sync import, purgatory entry) funnels through, and uses the existing AnnouncementResult rejection machinery so operators observe these rejections like any whitelist rejection. Public-mode behavior is unchanged (the gate is None outside private mode) and no configuration was added: the gate is implied by NGIT_PRIVATE_MODE. Correctness assumptions: membership is evaluated against the live set at admission time via PrivateAccess::contains, not re-derived; state events (kind 30618) stay governed by GRASP-01 maintainer rules; and removal is non-retroactive — repositories admitted while their author was a member remain hosted until the operator curates them. Deliberately excluded: outbound authentication when syncing from other private services, which remains a follow-up in the design doc. Validation: cargo clippy --all-targets -D warnings clean; lib tests (783), private_mode (52, incl. new member/non-member admission integration test), nip34_announcements (60), repository_creation (47), and purgatory (55) suites all pass.
This commit is contained in:
@@ -686,14 +686,23 @@ Optional endpoint at `/prs/<npub>/<identifier>.git`, gated on `NGIT_GRASP06_ENAB
|
||||
## Private Service Authentication (GRASP-08)
|
||||
|
||||
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.
|
||||
single `PrivateAccess` set is shared by the HTTP and WebSocket services and
|
||||
the announcement admission policy. Push authorization remains the GRASP-01
|
||||
policy; 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.
|
||||
|
||||
Because accepted announcements drive membership, announcement admission is
|
||||
itself membership-gated in private mode: a kind-30617 event is only admitted
|
||||
when its author is a current effective member at admission time, on every
|
||||
arrival path (direct publish, sync import, purgatory promotion). Non-member
|
||||
announcements are rejected through the normal announcement rejection
|
||||
machinery. State events (kind 30618) keep the GRASP-01 maintainer rules, and
|
||||
removal is non-retroactive — repositories admitted while their author was a
|
||||
member remain hosted until the operator curates them.
|
||||
|
||||
For Nostr, Hyper completes the public WebSocket upgrade and a message-level
|
||||
proxy sends and validates NIP-42 authentication before a connection reaches
|
||||
|
||||
@@ -113,6 +113,22 @@ connection, and reconciliation rides the existing five-second maintenance
|
||||
pass, so dynamic membership adds no polling, subscriptions, connections, or
|
||||
background tasks.
|
||||
|
||||
### Why announcement admission is membership-gated
|
||||
|
||||
Accepted announcements are not just hosting decisions: the NIP-11 owners of
|
||||
their referenced relays become members. Left ungated, a member could submit a
|
||||
third-party-signed kind-30617 announcement — or sync could import one from an
|
||||
operator-configured source — and thereby mint membership for pubkeys no
|
||||
member ever chose, which those pubkeys' relays could amplify further with
|
||||
announcements of their own. In private mode an announcement is therefore only
|
||||
admitted when its author (the event pubkey) is a current effective member,
|
||||
evaluated against the live member set at admission time; every arrival path
|
||||
(direct publish, sync import, purgatory promotion) funnels through the same
|
||||
admission policy. This closes the loop: hosting and derived membership can
|
||||
only expand through member action. State events (kind 30618) stay governed by
|
||||
GRASP-01 maintainer rules, and removal remains non-retroactive — an admitted
|
||||
repository is not evicted when its author later leaves the member set.
|
||||
|
||||
### Why purgatory announcements grant nothing
|
||||
|
||||
Purgatory holds announcements that have *not* passed repository admission.
|
||||
@@ -147,7 +163,8 @@ lock a user out.
|
||||
provide, without an extra fetch or format).
|
||||
- **Membership grants read access only.** Push authorization remains
|
||||
GRASP-01's maintainer model; repository admission remains announcement
|
||||
policy.
|
||||
policy, which in private mode additionally requires the announcement
|
||||
author to be a current effective member.
|
||||
- **Removal is not retroactive.** Removing a member closes their sessions and
|
||||
invalidates future credentials, but repositories admitted while they were a
|
||||
member remain hosted until the operator curates them.
|
||||
@@ -156,11 +173,6 @@ lock a user out.
|
||||
|
||||
Deliberately excluded from the initial single-service implementation:
|
||||
|
||||
- **Membership-gated announcement admission**: requiring announcement authors
|
||||
to be members, so hosting and derived membership can only expand through
|
||||
member action. (Non-members cannot reach the relay to publish, but members
|
||||
can currently submit third-party-signed announcements, and sync can import
|
||||
them from operator-configured sources.)
|
||||
- **Outbound authentication**: presenting NIP-42 and GRASP-08 NIP-98
|
||||
credentials when syncing *from* other private services, using a service
|
||||
identity key. This is the missing half of zero-configuration private
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::nostr::policy::{
|
||||
StateResult,
|
||||
};
|
||||
use crate::nostr::SharedDatabase;
|
||||
use crate::private::PrivateAccess;
|
||||
use crate::purgatory::promotion_hooks::NostrPurgatoryPromotionHooks;
|
||||
use crate::sync::rejected_index::RejectedEventsIndex;
|
||||
|
||||
@@ -112,6 +113,7 @@ impl Nip34WritePolicy {
|
||||
purgatory: std::sync::Arc<crate::purgatory::Purgatory>,
|
||||
config: crate::config::Config,
|
||||
repo_init_locks: crate::grasp06::receive::RepoInitLocks,
|
||||
private_access: Option<PrivateAccess>,
|
||||
) -> Self {
|
||||
let git_data_path = git_data_path.into();
|
||||
let domain = config.domain.clone();
|
||||
@@ -130,7 +132,7 @@ impl Nip34WritePolicy {
|
||||
|
||||
let ctx = PolicyContext::new(domain, database, git_data_path, purgatory, config.clone());
|
||||
Self {
|
||||
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone()),
|
||||
announcement_policy: AnnouncementPolicy::new(ctx.clone(), config.clone(), private_access),
|
||||
state_policy: StatePolicy::new(ctx.clone()),
|
||||
pr_event_policy: PrEventPolicy::new(ctx.clone(), repo_init_locks),
|
||||
related_event_policy: RelatedEventPolicy::new(ctx.clone()),
|
||||
@@ -939,6 +941,7 @@ pub async fn create_relay(
|
||||
config: &Config,
|
||||
purgatory: Arc<crate::purgatory::Purgatory>,
|
||||
repo_init_locks: crate::grasp06::receive::RepoInitLocks,
|
||||
private_access: Option<PrivateAccess>,
|
||||
) -> Result<RelayRuntime> {
|
||||
tracing::info!("Configuring nostr relay with GRASP-01 validation...");
|
||||
|
||||
@@ -1052,6 +1055,7 @@ pub async fn create_relay(
|
||||
purgatory,
|
||||
config.clone(),
|
||||
repo_init_locks,
|
||||
private_access,
|
||||
);
|
||||
|
||||
let mut builder = LocalRelayBuilder::default()
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Duration;
|
||||
use super::PolicyContext;
|
||||
use crate::config::Config;
|
||||
use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
|
||||
use crate::private::PrivateAccess;
|
||||
|
||||
/// Result of announcement policy evaluation
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -30,11 +31,21 @@ pub enum AnnouncementResult {
|
||||
pub struct AnnouncementPolicy {
|
||||
ctx: PolicyContext,
|
||||
config: Config,
|
||||
/// GRASP-08 live effective member set; `Some` only in private mode.
|
||||
private_access: Option<PrivateAccess>,
|
||||
}
|
||||
|
||||
impl AnnouncementPolicy {
|
||||
pub fn new(ctx: PolicyContext, config: Config) -> Self {
|
||||
Self { ctx, config }
|
||||
pub fn new(
|
||||
ctx: PolicyContext,
|
||||
config: Config,
|
||||
private_access: Option<PrivateAccess>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ctx,
|
||||
config,
|
||||
private_access,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a repository announcement event
|
||||
@@ -47,6 +58,21 @@ impl AnnouncementPolicy {
|
||||
/// - `AcceptArchive` if accepted via GRASP-05 archive config
|
||||
/// - `Reject` with reason if validation fails
|
||||
pub async fn validate(&self, event: &Event) -> AnnouncementResult {
|
||||
// GRASP-08: on a private service, admitting an announcement expands
|
||||
// hosting and — via its referenced relays' NIP-11 owners — the member
|
||||
// set itself. Only a current effective member may therefore introduce
|
||||
// one; the check runs against the live member set at admission time
|
||||
// and covers every arrival path, since all announcement admission
|
||||
// funnels through this method. Removal is not retroactive:
|
||||
// repositories admitted while their author was a member stay hosted.
|
||||
if let Some(access) = &self.private_access {
|
||||
if !access.contains(&event.pubkey) {
|
||||
return AnnouncementResult::Reject(
|
||||
"Announcement author is not authorized on this private service".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// First, try validation (GRASP-01 + GRASP-05)
|
||||
let validation_result = validate_announcement(event, &self.config);
|
||||
|
||||
@@ -479,3 +505,85 @@ impl AnnouncementPolicy {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Tag, ToBech32};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn policy(private_access: Option<PrivateAccess>) -> AnnouncementPolicy {
|
||||
let config = Config::for_testing();
|
||||
let ctx = PolicyContext::new_for_test(
|
||||
config.domain.clone(),
|
||||
Arc::new(nostr_memory::MemoryDatabase::unbounded()),
|
||||
PathBuf::new(),
|
||||
Arc::new(crate::purgatory::Purgatory::new(PathBuf::new())),
|
||||
config.clone(),
|
||||
);
|
||||
AnnouncementPolicy::new(ctx, config, private_access)
|
||||
}
|
||||
|
||||
/// A GRASP-01-valid announcement listing the test service in both the
|
||||
/// `clone` and `relays` tags.
|
||||
fn service_announcement(keys: &Keys, domain: &str) -> Event {
|
||||
let npub = keys.public_key().to_bech32().expect("author npub");
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(vec![
|
||||
Tag::identifier("membership-gate-repo"),
|
||||
Tag::custom(
|
||||
"clone",
|
||||
[format!("https://{domain}/{npub}/membership-gate-repo.git")],
|
||||
),
|
||||
Tag::custom("relays", [format!("wss://{domain}")]),
|
||||
])
|
||||
.finalize(keys)
|
||||
.expect("signed announcement")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_mode_rejects_announcement_from_nonmember_author() {
|
||||
let member = Keys::generate();
|
||||
let outsider = Keys::generate();
|
||||
let policy = policy(Some(PrivateAccess::new([member.public_key()])));
|
||||
|
||||
let event = service_announcement(&outsider, &policy.config.domain);
|
||||
let result = policy.validate(&event).await;
|
||||
|
||||
assert!(
|
||||
matches!(result, AnnouncementResult::Reject(_)),
|
||||
"non-member author must be rejected in private mode: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_mode_accepts_announcement_from_member_author() {
|
||||
let member = Keys::generate();
|
||||
let policy = policy(Some(PrivateAccess::new([member.public_key()])));
|
||||
|
||||
let event = service_announcement(&member, &policy.config.domain);
|
||||
let result = policy.validate(&event).await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
AnnouncementResult::AcceptPurgatory,
|
||||
"member-authored announcement must pass the membership gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_mode_ignores_the_membership_gate() {
|
||||
let author = Keys::generate();
|
||||
let policy = policy(None);
|
||||
|
||||
let event = service_announcement(&author, &policy.config.domain);
|
||||
let result = policy.validate(&event).await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
AnnouncementResult::AcceptPurgatory,
|
||||
"public mode admission must be unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -188,10 +188,14 @@ impl RelayServer {
|
||||
}
|
||||
|
||||
// Create Nostr relay runtime with NIP-34 validation and shared stores.
|
||||
let relay_runtime =
|
||||
nostr::builder::create_relay(&config, purgatory.clone(), repo_init_locks.clone())
|
||||
.await
|
||||
.context("failed to create relay runtime")?;
|
||||
let relay_runtime = nostr::builder::create_relay(
|
||||
&config,
|
||||
purgatory.clone(),
|
||||
repo_init_locks.clone(),
|
||||
private_access.clone(),
|
||||
)
|
||||
.await
|
||||
.context("failed to create relay runtime")?;
|
||||
|
||||
info!(
|
||||
"Relay created with NIP-34 validation for domain: {}",
|
||||
|
||||
@@ -8932,6 +8932,7 @@ mod tests {
|
||||
&config,
|
||||
purgatory,
|
||||
crate::grasp06::receive::RepoInitLocks::default(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create test relay runtime");
|
||||
|
||||
@@ -433,6 +433,7 @@ fn test_write_policy(
|
||||
purgatory,
|
||||
config,
|
||||
repo_init_locks,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ fn make_policy(
|
||||
purgatory,
|
||||
config,
|
||||
new_repo_init_locks(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ fn build_policy_for_history_regression(
|
||||
purgatory,
|
||||
config,
|
||||
new_repo_init_locks(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,85 @@ async fn private_websocket_rejects_valid_nonmember_auth_and_closes() {
|
||||
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;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_websocket_bounds_invalid_authentication_attempts() {
|
||||
let member = Keys::generate();
|
||||
|
||||
Reference in New Issue
Block a user