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