Merge #9af177ea: feat(private-repos): gate announcement admission on me…

feat(private-repos): gate announcement admission on membership

nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqsf4uthaf2cr5hes246tckccufhntfj3uc5su95t5l7z5p9g6s3jmq059w44

PR-Author: DanConwayDev's Agent
nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0

PR description:

In GRASP-08 private mode, accepted kind-30617 announcements expand hosting and - via their referenced relays NIP-11 owners - the effective member set itself. Announcement admission never checked the author, so a member could submit a third-party-signed announcement (or sync could import one from an operator-configured source) and thereby mint membership for pubkeys no member ever chose.

This PR closes that amplification loop: in private mode a repository announcement is only admitted when its author (event pubkey) is a current effective member (configured members plus admitted relay owners, via the shared PrivateAccess set) evaluated at admission time. The gate sits at the single choke point all arrival paths funnel through - direct publish, sync import, purgatory entry - and rejections use the existing announcement rejection machinery. Public mode is unchanged, no new configuration is added, state events (30618) keep GRASP-01 maintainer rules, and removal is non-retroactive: repositories admitted while their author was a member stay hosted until the operator curates them. Also documents the rationale in the GRASP-08 design doc and architecture doc.

Deliberately excluded: outbound authentication when syncing from other private services, which remains a separate follow-up.

Validation: cargo clippy --all-targets -D warnings clean; lib tests (783), private_mode (52, incl. a new member/non-member admission integration test), nip34_announcements (60), repository_creation (47), and purgatory (55) suites all pass.
This commit is contained in:
DanConwayDev
2026-08-15 11:15:32 +01:00
11 changed files with 418 additions and 15 deletions
+14
View File
@@ -201,6 +201,20 @@ Explanation documentation helps you **understand concepts** and design decisions
---
### [GRASP-08 Private Service Authentication](grasp-08-private-service.md)
**Service-wide NIP-42/NIP-98 authentication for private repositories**
**Topics:**
- Fail-closed private mode and indistinguishable 401 responses
- The GRASP-08 repository-scoped NIP-98 profile vs generic NIP-98
- NIP-42 authentication outside the embedded relay
- Service-wide membership and dynamic accepted-relay-owner admission
- Trust model and follow-up scope
**Read when:** You want to understand how a private GRASP instance authenticates clients and peers
---
### [Repository Lifecycle](repository-lifecycle.md)
**Handling repository removal, holding, archive, recovery, and purgatory**
+17 -8
View File
@@ -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
@@ -0,0 +1,181 @@
# GRASP-08: Private Service Authentication — Design
**Status**: Implemented, single-service scope (opt-in via `NGIT_PRIVATE_MODE`)
**Spec**: [GRASP-08](https://github.com/DanConwayDev/grasp/blob/main/08.md)
**Related**: [Architecture](architecture.md), [Defensive Measures](defensive-measures.md), [Repository Lifecycle](repository-lifecycle.md)
**Configuration**: [`NGIT_PRIVATE_MODE`, `NGIT_PRIVATE_MEMBERS`, `NGIT_PRIVATE_PUBLIC_ORIGIN`](../reference/configuration.md)
---
## Overview
GRASP-08 turns a GRASP service into a private one: repository events and Git
objects must not be readable merely because an endpoint is reachable. Every
Nostr WebSocket session must authenticate with NIP-42 and every standard Git
Smart HTTP request must carry a repository-scoped NIP-98 credential before any
repository data is served.
Private mode is a **service-wide access boundary**, not a per-repository ACL.
A private instance is one trust domain: everything it hosts is readable by
every member and by nobody else. Push authorization is unchanged — a private
credential proves membership, and GRASP-01's maintainer-based push rules still
decide who may write which repository.
## What GRASP-08 changes
1. **WebSocket**: after the public upgrade, a message-level proxy issues a
NIP-42 `AUTH` challenge and validates the response before the connection is
bridged to the embedded relay. Unauthenticated `REQ`/`EVENT`/`COUNT`/
`NEG-OPEN` messages receive machine-readable `auth-required:` rejections;
a valid authentication by a non-member receives `restricted:` and the
connection is closed. Three invalid attempts or thirty seconds of silence
terminate the connection.
2. **Git Smart HTTP**: requests under `/<npub>/<identifier>.git` must carry the
GRASP-08 profile of NIP-98 (see below). Authentication runs before
repository lookup or request-body collection.
3. **Membership**: one shared member set combines operator-configured npubs
(`NGIT_PRIVATE_MEMBERS`) with the NIP-11 owner pubkeys of relays referenced
by *accepted* repository announcements. Membership changes propagate to
live WebSocket sessions, which are closed when their pubkey is removed.
4. **Discovery stays public**: the NIP-11 document (which advertises NIPs 42
and 98), the NIP-05 root identity, the landing page, and the icon remain
unauthenticated so clients can discover the authentication requirement.
This deliberately discloses the operator identity and the service's
existence — private mode hides repository *content*, not the service.
Everything else — announcement purgatory, proactive sync, push authorization,
repository lifecycle — is unchanged.
## Why this shape
### Why every failure is the same empty 401
Missing, malformed, expired, and non-member Git credentials all receive an
identical empty `401 Unauthorized` with the same `WWW-Authenticate: Nostr`
challenge. Distinguishable failures would let an unauthenticated party probe
which repositories exist or which pubkeys are members. Authentication runs
before repository lookup for the same reason: a 404-before-auth would be an
existence oracle.
### Why the Git credential differs from generic NIP-98
Generic NIP-98 signs the exact request URL and method and is single-use. A Git
clone is not one request — it is a sequence of `info/refs` and pack-transfer
requests, issued by tooling that cannot re-sign per request. The GRASP-08
profile therefore signs the **canonical repository root** with method `GET`,
ignores payload tags, and is reusable across the standard Smart HTTP endpoints
for a 60-second validity window. Replay within the window is accepted: the
credential grants read access the holder already has for that window, and
write operations remain gated by GRASP-01 push authorization, so replay
confers nothing beyond what the member could do anyway.
The canonical public origin is operator-controlled
(`NGIT_PRIVATE_PUBLIC_ORIGIN`, falling back to `NGIT_DOMAIN`) so that a
reverse proxy cannot influence the identity that credentials sign.
### Why NIP-42 runs outside the embedded relay
`nostr-relay-builder`'s query and write policies do not receive the
authenticated session pubkey, so the access check cannot live inside them.
Instead the proxy authenticates first and only then bridges frames through an
in-memory WebSocket pair to `LocalRelay`. The bridge subscribes to a
membership generation channel; revoking a member closes their live sessions
instead of letting them ride out an old connection.
### Why membership is service-wide
Relays are referenced per repository in NIP-34 announcements, but access here
is per service. Reconciling the two per-repository would require per-session
subscription filtering inside the embedded relay (which the policy interfaces
cannot express, see above) and would turn every query into an ACL join. The
single-service model instead declares the whole instance one trust domain —
the deployment it targets is a team running a private service for its own
repositories. Hosting mutually-distrusting sub-groups on one instance is
explicitly out of scope and belongs to the future multi-service/fleet
proposal. Under this model, per-repository relay lists act as **sync topology
hints** (who to connect to), while the service-wide member set is the **ACL**
(who may read).
### Why accepted-relay owners are admitted dynamically
Two private services mirroring the same repository must be able to read from
each other. When an accepted announcement references another relay, that
relay's NIP-11 `pubkey` is added to the member set, so a peer service (or the
operator of an ordinary relay the team uses) can authenticate without manual
whitelisting on both sides. The consequence — accepting one announcement
grants its referenced relay operators read access to the whole service —
follows directly from the one-trust-domain model and is the operator's
opt-in via announcement admission.
Discovery reuses the NIP-11 document already fetched once per sync
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.
If purgatory state could contribute relay owners to the member set, anyone
able to get an event into purgatory could mint members. Only announcements
accepted into the repository index count.
### Why private mode refuses to combine with GRASP-06
The GRASP-06 contributor endpoint (`/prs/`) is intentionally unauthenticated —
its entire design premise is that any contributor can push a PR (see
[GRASP-06 design](grasp-06-contributor-pr-submission.md)). That premise is
incompatible with a private service, so `NGIT_PRIVATE_MODE=true` together
with `NGIT_GRASP06_ENABLE=true` is a fatal configuration error rather than a
silently half-open service.
### Why configuration fails closed
Private mode without members would lock everyone out silently, and members
without private mode would suggest an operator believes the service is
private when it is not. Both are startup errors. Malformed member npubs are
fatal rather than skipped: dropping an access-control entry would silently
lock a user out.
## Trust model summary
- **Configured members** (`NGIT_PRIVATE_MEMBERS`) are the permanent base set,
trusted by operator assertion.
- **Accepted-relay owners** are derived members, trusted transitively via
announcement admission plus the referenced relay's NIP-11 self-assertion
(the same HTTPS-from-domain trust anchor a `_@domain` NIP-05 lookup would
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, 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.
## Follow-up scope
Deliberately excluded from the initial single-service implementation:
- **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
mirroring.
- **Multi-service fleet orchestration** and **encrypted kind-10318 client
discovery**, which belong to future GRASP proposals.
+5 -1
View File
@@ -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()
+110 -2
View File
@@ -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
View File
@@ -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: {}",
+1
View File
@@ -8932,6 +8932,7 @@ mod tests {
&config,
purgatory,
crate::grasp06::receive::RepoInitLocks::default(),
None,
)
.await
.expect("create test relay runtime");
+1
View File
@@ -433,6 +433,7 @@ fn test_write_policy(
purgatory,
config,
repo_init_locks,
None,
)
}
+1
View File
@@ -111,6 +111,7 @@ fn make_policy(
purgatory,
config,
new_repo_init_locks(),
None,
)
}
+1
View File
@@ -133,6 +133,7 @@ fn build_policy_for_history_regression(
purgatory,
config,
new_repo_init_locks(),
None,
)
}
+79
View File
@@ -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();