feat(private-repos): admit accepted relay owners

GRASP-08 requires the service whitelist to include NIP-11 owners of relays referenced by accepted announcements. Static configuration alone would incorrectly deny those relay operators and make repository synchronization between private services unusable.

Carry the owner field through the existing once-per-session NIP-11 limit fetch, retain the latest owner per canonical relay, and reconcile the shared inbound access set from configured members plus owners whose relays appear in Full repository sync entries. StateOnly purgatory entries are excluded because unaccepted peer input must not grant access. Reconciliation runs on connection completion and the existing five-second maintenance cadence; unchanged sets do not churn authenticated sessions.

This adds no connection, request, subscription, or configuration option. Multi-service fleet orchestration and encrypted kind-10318 discovery remain separate future work.

Validation: cargo check --all-targets passed; focused private authentication tests passed (7/7); unit coverage verifies owner parsing without a NIP-11 limitation object, configured/accepted/unrelated membership selection, and no generation change for an identical replacement. Full suite validation follows on the completed two-commit stack.
This commit is contained in:
DanConwayDev
2026-08-14 21:33:49 +00:00
parent d7766dace7
commit 6b4d585bf0
9 changed files with 175 additions and 29 deletions
+4 -2
View File
@@ -311,8 +311,10 @@
# Default: false
# NGIT_PRIVATE_MODE=false
# Service-wide member whitelist as comma-separated npubs. Every entry is
# validated at startup; malformed entries fail closed.
# Permanently configured service-wide members as comma-separated npubs. Every
# entry is validated at startup; malformed entries fail closed. The effective
# GRASP-08 whitelist also includes NIP-11 owners of relays referenced by
# accepted repository announcements.
#
# CLI: --private-members <npubs>
# Default: (empty; at least one member is required in private mode)
+11
View File
@@ -689,6 +689,11 @@ 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.
For Nostr, Hyper completes the public WebSocket upgrade and a message-level
proxy sends and validates NIP-42 authentication before a connection reaches
@@ -711,6 +716,12 @@ Private mode advertises GRASP-08 plus NIP-42 and NIP-98 in NIP-11. It is
incompatible with GRASP-06 because that extension deliberately exposes an
unauthenticated contributor write surface.
One process currently represents one private collaborator service. Operators
can run several independently configured instances for different groups. Fleet
provisioning and lifecycle automation are deliberately left to a later change;
they are deployment conveniences rather than part of the authentication
boundary implemented here.
## Future Extensions
### GRASP-02: Proactive Sync
+7 -1
View File
@@ -1021,7 +1021,7 @@ GRASP-06 contributor endpoint is intentionally unauthenticated.
#### `NGIT_PRIVATE_MEMBERS`
**Description:** Service-wide private-service member whitelist
**Description:** Permanently configured members of the service-wide private-service whitelist
**Type:** Comma-separated npubs
**Default:** Empty
**Required:** Yes when `NGIT_PRIVATE_MODE=true`
@@ -1030,6 +1030,12 @@ GRASP-06 contributor endpoint is intentionally unauthenticated.
NGIT_PRIVATE_MEMBERS=npub1alice...,npub1bob...
```
The effective GRASP-08 whitelist is the union of these configured members and
the NIP-11 owner pubkeys of relays referenced by accepted repository
announcements. Relay owners are learned by the NIP-11 request already made for
sync limits; this does not open another connection or subscription. A relay
listed only by an unpromoted purgatory announcement cannot grant access.
#### `NGIT_PRIVATE_PUBLIC_ORIGIN`
Optional canonical external HTTP origin for private Git authentication, such
+5 -3
View File
@@ -406,9 +406,11 @@ let
default = [ ];
example = [ "npub1alice..." "npub1bob..." ];
description = ''
Service-wide GRASP-08 member whitelist. At least one valid npub is
required when privateMode is enabled. Malformed entries make the
service fail at startup.
Permanently configured GRASP-08 service members. The effective
whitelist also includes NIP-11 owners of relays referenced by
accepted repository announcements. At least one valid configured
npub is required when privateMode is enabled; malformed entries make
the service fail at startup.
'';
};
+4 -2
View File
@@ -599,9 +599,11 @@ pub struct Config {
#[arg(long, env = "NGIT_PRIVATE_MODE", default_value_t = false)]
pub private_mode: bool,
/// Service-wide GRASP-08 member whitelist as comma-separated npubs.
/// Permanently configured GRASP-08 members as comma-separated npubs.
///
/// Required and fail-closed when private mode is enabled.
/// The effective whitelist also includes NIP-11 owners of relays referenced
/// by accepted repository announcements. Required and fail-closed when
/// private mode is enabled.
#[arg(long, env = "NGIT_PRIVATE_MEMBERS", default_value = "")]
pub private_members: String,
+14 -4
View File
@@ -51,14 +51,21 @@ impl PrivateAccess {
}
/// Atomically replace membership and notify active sessions.
pub fn replace(&self, members: impl IntoIterator<Item = PublicKey>) {
*self
pub fn replace(&self, members: impl IntoIterator<Item = PublicKey>) -> bool {
let members = members.into_iter().collect();
let mut current = self
.inner
.members
.write()
.expect("private access lock poisoned") = members.into_iter().collect();
.expect("private access lock poisoned");
if *current == members {
return false;
}
*current = members;
drop(current);
let next = self.inner.generation.borrow().wrapping_add(1);
self.inner.generation.send_replace(next);
true
}
pub fn subscribe(&self) -> watch::Receiver<u64> {
@@ -81,9 +88,12 @@ mod tests {
assert!(access.contains(&first));
assert!(!access.contains(&second));
access.replace([second]);
assert!(access.replace([second]));
assert!(!access.contains(&first));
assert!(access.contains(&second));
assert_eq!(*generation.borrow(), 1);
assert!(!access.replace([second]));
assert_eq!(*generation.borrow(), 1);
}
}
+1
View File
@@ -233,6 +233,7 @@ impl RelayServer {
&config,
PathBuf::from(config.effective_git_data_path()),
metrics.as_ref().and_then(|m| m.sync_metrics().cloned()),
private_access.clone(),
);
if config.sync_bootstrap_relay_url.is_some() {
+94
View File
@@ -57,6 +57,7 @@ use crate::nostr::SharedDatabase;
use crate::outbound::{
url_matches_service_domain, OutboundTargetKind, OutboundTargetPolicy, RelayTargetSource,
};
use crate::private::PrivateAccess;
use nostr_sdk::prelude::LocalRelay;
const MAX_PURGATORY_DEPENDENCY_EVENTS_PER_TICK: usize = 32;
@@ -207,6 +208,22 @@ fn dependency_relay_retention() -> Duration {
}
}
fn effective_private_members(
configured: &HashSet<PublicKey>,
accepted_relays: &HashSet<String>,
relay_owners: &HashMap<String, PublicKey>,
) -> HashSet<PublicKey> {
configured
.iter()
.copied()
.chain(
relay_owners
.iter()
.filter_map(|(relay, owner)| accepted_relays.contains(relay).then_some(*owner)),
)
.collect()
}
fn byte_limited_catchup_interval() -> Duration {
if std::env::var("NGIT_TEST").as_deref() == Ok("1") {
Duration::from_secs(2)
@@ -1767,6 +1784,7 @@ enum ConnectAttemptOutcome {
Connected {
advertised_default_limit: Option<usize>,
advertised_max_subscriptions: Option<usize>,
advertised_owner: Option<PublicKey>,
},
Failed(String),
}
@@ -2201,6 +2219,7 @@ async fn run_purgatory_announcement_sync(
_ = tokio::time::sleep(interval) => {
let mut manager = sync_manager.lock().await;
manager.sync_purgatory_announcements_to_index().await;
manager.reconcile_private_membership().await;
manager.tick_missing_event_recovery().await;
manager.tick_descendant_sync().await;
}
@@ -2443,6 +2462,12 @@ pub struct SyncManager {
repo_sync_index: RepoSyncIndex,
root_candidate_index: RootCandidateIndex,
proactive_participant_authors: crate::nostr::policy::SharedProactiveParticipantAuthorIndex,
/// GRASP-08 access shared with the inbound HTTP/WebSocket boundary.
private_access: Option<PrivateAccess>,
/// Operator-configured members form the permanent base of private access.
configured_private_members: HashSet<PublicKey>,
/// Latest NIP-11 owner learned for each connected repository relay.
relay_owners: HashMap<String, PublicKey>,
/// What we've confirmed syncing + connection state
relay_sync_index: RelaySyncIndex,
/// In-flight subscription batches
@@ -2544,6 +2569,7 @@ impl SyncManager {
config: &Config,
data_path: PathBuf,
sync_metrics: Option<SyncMetrics>,
private_access: Option<PrivateAccess>,
) -> Self {
// Extract purgatory from write_policy for read-only access
let purgatory = write_policy.purgatory().clone();
@@ -2579,6 +2605,11 @@ impl SyncManager {
}
let proactive_participant_authors = write_policy.proactive_participant_authors();
let configured_private_members = config
.parse_private_members()
.expect("private members were validated before SyncManager construction")
.into_iter()
.collect();
Self {
bootstrap_relay_url,
service_domain,
@@ -2590,6 +2621,9 @@ impl SyncManager {
repo_sync_index: Arc::new(RwLock::new(HashMap::new())),
root_candidate_index: Arc::new(RwLock::new(HashMap::new())),
proactive_participant_authors,
private_access,
configured_private_members,
relay_owners: HashMap::new(),
relay_sync_index: Arc::new(RwLock::new(HashMap::new())),
pending_sync_index: Arc::new(RwLock::new(HashMap::new())),
rejected_events_index,
@@ -5431,6 +5465,7 @@ impl SyncManager {
ConnectAttemptOutcome::Connected {
advertised_default_limit: hints.default_limit,
advertised_max_subscriptions: hints.max_subscriptions,
advertised_owner: hints.owner,
}
},
Err(error) => ConnectAttemptOutcome::Failed(error),
@@ -5461,6 +5496,38 @@ impl SyncManager {
targets
}
/// Rebuild GRASP-08 membership from accepted-announcement sync state and
/// the latest NIP-11 owner learned for each referenced relay.
///
/// Purgatory announcements are deliberately excluded: they have not yet
/// passed repository admission and therefore cannot grant service access.
async fn reconcile_private_membership(&self) {
let Some(access) = &self.private_access else {
return;
};
let repo_index = self.repo_sync_index.read().await;
let accepted_relays: HashSet<String> = repo_index
.values()
.filter(|needs| needs.sync_level == SyncLevel::Full)
.flat_map(|needs| needs.relays.iter())
.filter_map(|relay| canonical_relay_key(relay).ok())
.collect();
drop(repo_index);
let members = effective_private_members(
&self.configured_private_members,
&accepted_relays,
&self.relay_owners,
);
if access.replace(members) {
tracing::info!(
configured_members = self.configured_private_members.len(),
accepted_relay_count = accepted_relays.len(),
effective_members = access.len(),
"Reconciled GRASP-08 service membership"
);
}
}
fn configured_nip65_fallback_relays(&self) -> HashSet<String> {
self.config
.parse_sync_plus_fallback_relays()
@@ -6185,7 +6252,17 @@ impl SyncManager {
ConnectAttemptOutcome::Connected {
advertised_default_limit,
advertised_max_subscriptions,
advertised_owner,
} => {
match advertised_owner {
Some(owner) => {
self.relay_owners.insert(result.relay_url.clone(), owner);
}
None => {
self.relay_owners.remove(&result.relay_url);
}
}
self.reconcile_private_membership().await;
if let Some(connection) = self.connections.get(&result.relay_url) {
connection.reset_subscription_budget(advertised_max_subscriptions);
}
@@ -8915,6 +8992,23 @@ mod tests {
assert!(!rejected.contains(&child.id));
}
#[test]
fn private_members_include_only_owners_of_accepted_relays() {
let configured = Keys::generate().public_key();
let accepted_owner = Keys::generate().public_key();
let unrelated_owner = Keys::generate().public_key();
let members = effective_private_members(
&HashSet::from([configured]),
&HashSet::from(["wss://accepted.example/".to_string()]),
&HashMap::from([
("wss://accepted.example/".to_string(), accepted_owner),
("wss://unrelated.example/".to_string(), unrelated_owner),
]),
);
assert_eq!(members, HashSet::from([configured, accepted_owner]));
}
#[test]
fn partial_nip65_batch_retries_missing_authors_early() {
let returned = Keys::generate().public_key();
+35 -17
View File
@@ -438,6 +438,28 @@ type LiveReqPermitMap = std::sync::Arc<
pub struct RelayLimitHints {
pub default_limit: Option<usize>,
pub max_subscriptions: Option<usize>,
/// Relay operator identity advertised by NIP-11. Private GRASP services
/// grant this identity access only when the relay is referenced by an
/// accepted repository announcement.
pub owner: Option<PublicKey>,
}
fn parse_relay_limit_hints(body: &str) -> RelayLimitHints {
let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok() else {
return RelayLimitHints::default();
};
let limitation = document.limitation.unwrap_or_default();
RelayLimitHints {
default_limit: limitation
.default_limit
.and_then(|limit| usize::try_from(limit).ok())
.filter(|limit| *limit > 0),
max_subscriptions: limitation
.max_subscriptions
.and_then(|limit| usize::try_from(limit).ok())
.filter(|limit| *limit > 0),
owner: document.pubkey,
}
}
/// How a failed negentropy diff should affect future NIP-77 attempts.
@@ -941,23 +963,7 @@ impl RelayConnection {
return RelayLimitHints::default();
}
};
let Some(document) = nostr::nips::nip11::RelayInformationDocument::from_json(body).ok()
else {
return RelayLimitHints::default();
};
let Some(limitation) = document.limitation else {
return RelayLimitHints::default();
};
RelayLimitHints {
default_limit: limitation
.default_limit
.and_then(|limit| usize::try_from(limit).ok())
.filter(|limit| *limit > 0),
max_subscriptions: limitation
.max_subscriptions
.and_then(|limit| usize::try_from(limit).ok())
.filter(|limit| *limit > 0),
}
parse_relay_limit_hints(&body)
}
/// Whether the SDK still considers this relay's WebSocket established.
@@ -2745,6 +2751,18 @@ impl RelayConnection {
mod tests {
use super::*;
#[test]
fn nip11_owner_is_retained_without_a_limitation_object() {
let owner = Keys::generate().public_key();
let body = format!(r#"{{"pubkey":"{}"}}"#, owner.to_hex());
let hints = parse_relay_limit_hints(&body);
assert_eq!(hints.owner, Some(owner));
assert_eq!(hints.default_limit, None);
assert_eq!(hints.max_subscriptions, None);
}
/// Event-directed connection with the permissive policy used by tests
/// that dial loopback fixtures.
fn permissive_connection(url: &str, keys: Keys) -> RelayConnection {