mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: relay membership with NIP-43 compliance (#448)
This commit is contained in:
@@ -57,7 +57,25 @@ pub const KIND_NIP29_JOIN_REQUEST: u32 = 9021;
|
||||
/// NIP-29: Request to leave a group.
|
||||
pub const KIND_NIP29_LEAVE_REQUEST: u32 = 9022;
|
||||
|
||||
// System / admin (9031–9999)
|
||||
// NIP-43 relay membership admin commands
|
||||
/// NIP-43: Add a pubkey to the relay member list.
|
||||
pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030;
|
||||
/// NIP-43: Remove a pubkey from the relay member list.
|
||||
pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031;
|
||||
/// NIP-43: Change the role of an existing relay member.
|
||||
pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032;
|
||||
|
||||
// NIP-43 relay membership announcement events (relay-signed)
|
||||
/// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention).
|
||||
pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534;
|
||||
/// NIP-43: Member added announcement (relay-signed).
|
||||
pub const KIND_NIP43_MEMBER_ADDED: u32 = 8000;
|
||||
/// NIP-43: Member removed announcement (relay-signed).
|
||||
pub const KIND_NIP43_MEMBER_REMOVED: u32 = 8001;
|
||||
/// NIP-43: User leave request (user-signed, ephemeral).
|
||||
pub const KIND_NIP43_LEAVE_REQUEST: u32 = 28936;
|
||||
|
||||
// System / admin (9100–9999)
|
||||
/// V1 used kind:9001 — moved here due to NIP-29 conflict.
|
||||
pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100;
|
||||
/// V1 used kind:9010 — moved here for NIP-29 range safety.
|
||||
@@ -280,6 +298,13 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_NIP29_CREATE_INVITE,
|
||||
KIND_NIP29_JOIN_REQUEST,
|
||||
KIND_NIP29_LEAVE_REQUEST,
|
||||
RELAY_ADMIN_ADD_MEMBER,
|
||||
RELAY_ADMIN_REMOVE_MEMBER,
|
||||
RELAY_ADMIN_CHANGE_ROLE,
|
||||
KIND_NIP43_MEMBERSHIP_LIST,
|
||||
KIND_NIP43_MEMBER_ADDED,
|
||||
KIND_NIP43_MEMBER_REMOVED,
|
||||
KIND_NIP43_LEAVE_REQUEST,
|
||||
KIND_SYSTEM_TIMER_FIRED,
|
||||
KIND_SYSTEM_SLASH_COMMAND,
|
||||
KIND_SYSTEM_FLAG,
|
||||
@@ -388,6 +413,14 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool {
|
||||
kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED
|
||||
}
|
||||
|
||||
/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032).
|
||||
pub const fn is_relay_admin_kind(kind: u32) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the kind from a nostr Event as u32.
|
||||
/// NIP-01 specifies kind as an unsigned integer; u32 covers the full range.
|
||||
pub fn event_kind_u32(event: &nostr::Event) -> u32 {
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod feed;
|
||||
pub mod partition;
|
||||
/// Reaction persistence.
|
||||
pub mod reaction;
|
||||
/// Relay-level membership persistence (NIP-43).
|
||||
pub mod relay_members;
|
||||
/// Thread metadata persistence.
|
||||
pub mod thread;
|
||||
/// User profile persistence.
|
||||
@@ -1311,6 +1313,75 @@ impl Db {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── Relay Members (NIP-43) ───────────────────────────────────────────────
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
|
||||
pub async fn is_relay_member(&self, pubkey: &str) -> Result<bool> {
|
||||
relay_members::is_relay_member(&self.pool, pubkey).await
|
||||
}
|
||||
|
||||
/// Returns the relay member record for `pubkey`, or `None` if not found.
|
||||
pub async fn get_relay_member(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
) -> Result<Option<relay_members::RelayMember>> {
|
||||
relay_members::get_relay_member(&self.pool, pubkey).await
|
||||
}
|
||||
|
||||
/// Returns all relay members ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(&self) -> Result<Vec<relay_members::RelayMember>> {
|
||||
relay_members::list_relay_members(&self.pool).await
|
||||
}
|
||||
|
||||
/// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent).
|
||||
/// Adds a new relay member.
|
||||
///
|
||||
/// Returns `true` if the row was actually inserted, `false` if the pubkey
|
||||
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
|
||||
pub async fn add_relay_member(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
role: &str,
|
||||
added_by: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await
|
||||
}
|
||||
|
||||
/// Removes a relay member atomically, refusing to delete the owner.
|
||||
pub async fn remove_relay_member(&self, pubkey: &str) -> Result<relay_members::RemoveResult> {
|
||||
relay_members::remove_relay_member(&self.pool, pubkey).await
|
||||
}
|
||||
|
||||
/// Removes a relay member only if their current role matches `expected_role`.
|
||||
///
|
||||
/// Atomic conditional delete — eliminates the TOCTOU race between a
|
||||
/// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`].
|
||||
pub async fn remove_relay_member_if_role(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
expected_role: &str,
|
||||
) -> Result<relay_members::RemoveResult> {
|
||||
relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await
|
||||
}
|
||||
|
||||
/// Updates the role of an existing relay member. Returns `true` if updated.
|
||||
pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result<bool> {
|
||||
relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await
|
||||
}
|
||||
|
||||
/// Ensures the owner pubkey exists with role `"owner"`. Called at startup.
|
||||
pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> {
|
||||
relay_members::bootstrap_owner(&self.pool, owner_pubkey).await
|
||||
}
|
||||
|
||||
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
|
||||
///
|
||||
/// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows
|
||||
/// inserted, or 0 if the `pubkey_allowlist` table doesn't exist.
|
||||
pub async fn backfill_from_allowlist(&self) -> Result<u64> {
|
||||
relay_members::backfill_from_allowlist(&self.pool).await
|
||||
}
|
||||
|
||||
// ── Discovery events ─────────────────────────────────────────────────────
|
||||
|
||||
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Relay-level membership persistence (NIP-43).
|
||||
//!
|
||||
//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata.
|
||||
//! All pubkey values are 64-char lowercase hex strings.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row as _};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// A single relay member record.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RelayMember {
|
||||
/// 64-char lowercase hex pubkey.
|
||||
pub pubkey: String,
|
||||
/// Role: `"owner"`, `"admin"`, or `"member"`.
|
||||
pub role: String,
|
||||
/// Hex pubkey of who added this member, or `None` for bootstrap entries.
|
||||
pub added_by: Option<String>,
|
||||
/// When the member was added.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the record was last updated.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
|
||||
pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result<bool> {
|
||||
let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// Returns the relay member record for `pubkey`, or `None` if not found.
|
||||
pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<RelayMember>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT pubkey, role, added_by, created_at, updated_at \
|
||||
FROM relay_members WHERE pubkey = $1",
|
||||
)
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(|r| -> std::result::Result<RelayMember, sqlx::Error> {
|
||||
Ok(RelayMember {
|
||||
pubkey: r.try_get("pubkey")?,
|
||||
role: r.try_get("role")?,
|
||||
added_by: r.try_get("added_by")?,
|
||||
created_at: r.try_get("created_at")?,
|
||||
updated_at: r.try_get("updated_at")?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Returns all relay members ordered by `created_at` ascending.
|
||||
pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT pubkey, role, added_by, created_at, updated_at \
|
||||
FROM relay_members ORDER BY created_at ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| -> std::result::Result<RelayMember, sqlx::Error> {
|
||||
Ok(RelayMember {
|
||||
pubkey: r.try_get("pubkey")?,
|
||||
role: r.try_get("role")?,
|
||||
added_by: r.try_get("added_by")?,
|
||||
created_at: r.try_get("created_at")?,
|
||||
updated_at: r.try_get("updated_at")?,
|
||||
})
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, sqlx::Error>>()
|
||||
.map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Adds a new relay member.
|
||||
///
|
||||
/// Returns `true` if the row was actually inserted, `false` if the pubkey
|
||||
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
|
||||
pub async fn add_relay_member(
|
||||
pool: &PgPool,
|
||||
pubkey: &str,
|
||||
role: &str,
|
||||
added_by: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO relay_members (pubkey, role, added_by) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING",
|
||||
)
|
||||
.bind(pubkey)
|
||||
.bind(role)
|
||||
.bind(added_by)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// The result of a relay member removal attempt.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RemoveResult {
|
||||
/// Member was successfully removed.
|
||||
Removed,
|
||||
/// The pubkey belongs to the relay owner — removal is forbidden.
|
||||
IsOwner,
|
||||
/// No member with the given pubkey exists.
|
||||
NotFound,
|
||||
/// The member exists but their role doesn't match the expected role.
|
||||
RoleMismatch,
|
||||
}
|
||||
|
||||
/// Removes a relay member atomically, refusing to delete the owner.
|
||||
///
|
||||
/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the
|
||||
/// owner-protection check and the deletion are one atomic operation —
|
||||
/// no TOCTOU race between a separate read and delete.
|
||||
pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveResult> {
|
||||
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'")
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
return Ok(RemoveResult::Removed);
|
||||
}
|
||||
|
||||
// rows_affected == 0: either not found or is owner. One cheap read to
|
||||
// distinguish the two cases so callers can return the right error message.
|
||||
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if exists.is_some() {
|
||||
Ok(RemoveResult::IsOwner)
|
||||
} else {
|
||||
Ok(RemoveResult::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a relay member only if their current role matches `expected_role`.
|
||||
///
|
||||
/// The delete and the role check are collapsed into a single
|
||||
/// `DELETE … WHERE pubkey = $1 AND role = $2`, making the operation atomic —
|
||||
/// no TOCTOU race between a prior read and this delete.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Removed` — row was deleted.
|
||||
/// - `NotFound` — no member with that pubkey exists.
|
||||
/// - `IsOwner` — member exists with role `"owner"` (cannot be removed).
|
||||
/// - `RoleMismatch` — member exists but their role no longer matches
|
||||
/// `expected_role` (e.g., they were promoted between the caller's read and
|
||||
/// this delete).
|
||||
pub async fn remove_relay_member_if_role(
|
||||
pool: &PgPool,
|
||||
pubkey: &str,
|
||||
expected_role: &str,
|
||||
) -> Result<RemoveResult> {
|
||||
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2")
|
||||
.bind(pubkey)
|
||||
.bind(expected_role)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
return Ok(RemoveResult::Removed);
|
||||
}
|
||||
|
||||
// rows_affected == 0: either not found or role changed. One cheap read to
|
||||
// distinguish the cases so callers can return the right error message.
|
||||
let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1")
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
None => Ok(RemoveResult::NotFound),
|
||||
Some(r) => {
|
||||
let role: String = r.try_get("role")?;
|
||||
if role == "owner" {
|
||||
Ok(RemoveResult::IsOwner)
|
||||
} else {
|
||||
// Role changed between the caller's check and this delete
|
||||
// (e.g., target was promoted to admin). Signal that the
|
||||
// caller no longer has authority to remove this target.
|
||||
Ok(RemoveResult::RoleMismatch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the role of an existing relay member. Returns `true` if updated.
|
||||
pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'",
|
||||
)
|
||||
.bind(new_role)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes
|
||||
/// any other owners to `"admin"`. This handles owner rotation: if
|
||||
/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted.
|
||||
///
|
||||
/// Runs in a single transaction. Safe to call at every startup — idempotent.
|
||||
pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
|
||||
let pubkey = owner_pubkey.to_ascii_lowercase();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 1. Upsert the configured owner.
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (pubkey, role, added_by) \
|
||||
VALUES ($1, 'owner', NULL) \
|
||||
ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
|
||||
)
|
||||
.bind(&pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 2. Demote any other owners to admin.
|
||||
sqlx::query(
|
||||
"UPDATE relay_members SET role = 'admin', updated_at = now() \
|
||||
WHERE role = 'owner' AND pubkey <> $1",
|
||||
)
|
||||
.bind(&pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
|
||||
///
|
||||
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members.
|
||||
/// Returns the number of rows inserted, or 0 if:
|
||||
/// - the `pubkey_allowlist` table doesn't exist, or
|
||||
/// - `relay_members` already has rows (migration ran in a prior startup).
|
||||
///
|
||||
/// The empty-table guard prevents re-adding members that were intentionally
|
||||
/// removed by an admin after the initial backfill.
|
||||
pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
|
||||
// Check if pubkey_allowlist table exists.
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
|
||||
WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if !exists {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Only backfill if relay_members is empty — once the table has rows
|
||||
// (from a previous backfill or manual admin commands), we must not
|
||||
// re-add members that were intentionally removed.
|
||||
let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if has_members {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO relay_members (pubkey, role, added_by, created_at) \
|
||||
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \
|
||||
FROM pubkey_allowlist \
|
||||
ON CONFLICT (pubkey) DO NOTHING",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -50,6 +50,8 @@ pub enum MediaError {
|
||||
Unauthorized,
|
||||
#[error("insufficient scope")]
|
||||
InsufficientScope,
|
||||
#[error("relay membership required")]
|
||||
RelayMembershipRequired,
|
||||
#[error("token revoked")]
|
||||
TokenRevoked,
|
||||
#[error("pubkey mismatch")]
|
||||
@@ -130,6 +132,7 @@ impl IntoResponse for MediaError {
|
||||
)
|
||||
}
|
||||
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::RelayMembershipRequired => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::UnsupportedContainer => (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()),
|
||||
Self::WrongCodec
|
||||
| Self::DurationTooLong
|
||||
|
||||
@@ -170,6 +170,15 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
// The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient
|
||||
// replay protection for v1. Per-request signing requires protocol changes.
|
||||
|
||||
// Relay membership gate (NIP-43).
|
||||
if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
|
||||
return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response());
|
||||
}
|
||||
|
||||
Ok(GitAuth { pubkey })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
|
||||
sprout_auth::require_scope(&scopes, Scope::FilesWrite)
|
||||
.map_err(|_| MediaError::InsufficientScope)?;
|
||||
|
||||
// 5. Relay membership gate (NIP-43).
|
||||
crate::api::relay_members::enforce_relay_membership(state, &auth_event.pubkey.serialize())
|
||||
.await
|
||||
.map_err(|_| MediaError::RelayMembershipRequired)?;
|
||||
|
||||
Ok(AuthenticatedUpload { auth_event, scopes })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ pub mod nip05;
|
||||
pub mod presence;
|
||||
/// Reaction endpoints.
|
||||
pub mod reactions;
|
||||
/// Relay membership enforcement and read endpoints.
|
||||
pub mod relay_members;
|
||||
/// Full-text search endpoint.
|
||||
pub mod search;
|
||||
/// Self-service API token minting, listing, and revocation endpoints.
|
||||
@@ -64,6 +66,7 @@ pub use members::list_members;
|
||||
pub use messages::{get_thread, list_messages, validate_imeta_tags, verify_imeta_blobs};
|
||||
pub use presence::{presence_handler, set_presence_handler};
|
||||
pub use reactions::list_reactions_handler;
|
||||
pub use relay_members::{enforce_relay_membership, get_my_relay_membership, list_relay_members};
|
||||
pub use search::search_handler;
|
||||
pub use users::{
|
||||
get_contact_list, get_profile, get_user_notes, get_user_profile, get_users_batch,
|
||||
@@ -133,7 +136,7 @@ pub struct RestAuthContext {
|
||||
pub channel_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Extract the full auth context from request headers.
|
||||
/// Extract the full auth context from request headers and enforce relay membership.
|
||||
///
|
||||
/// Auth resolution order:
|
||||
/// 1. `Authorization: Bearer sprout_*` — API token; revocation + expiry checked here
|
||||
@@ -145,10 +148,27 @@ pub struct RestAuthContext {
|
||||
/// request that sends a `Nostr` auth header to a non-token endpoint will receive
|
||||
/// a 401 with `"nip98_not_supported"`.
|
||||
///
|
||||
/// Returns a populated [`RestAuthContext`] on success, or a 401 response on failure.
|
||||
/// After successful authentication, relay membership is enforced when
|
||||
/// `config.require_relay_membership` is enabled.
|
||||
///
|
||||
/// Returns a populated [`RestAuthContext`] on success, or a 401/403 response on failure.
|
||||
pub(crate) async fn extract_auth_context(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ctx = extract_auth_context_inner(headers, state).await?;
|
||||
relay_members::enforce_relay_membership(state, &ctx.pubkey_bytes).await?;
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
/// Inner auth extraction — no relay membership check.
|
||||
///
|
||||
/// Used by `extract_auth_context` (which layers the membership gate on top)
|
||||
/// and by handlers that need auth without the membership gate (e.g. the
|
||||
/// `/api/relay/members/me` endpoint which must work for non-members to return 404).
|
||||
pub(crate) async fn extract_auth_context_inner(
|
||||
headers: &HeaderMap,
|
||||
state: &AppState,
|
||||
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
|
||||
let require_auth = state.config.require_auth_token;
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Relay membership enforcement and read endpoints.
|
||||
//!
|
||||
//! ## Enforcement
|
||||
//! [`enforce_relay_membership`] is the single gate — called at every authenticated
|
||||
//! entry point. When `require_relay_membership` is disabled, it's a no-op.
|
||||
//!
|
||||
//! ## Routes
|
||||
//! - `GET /api/relay/members` — list all relay members (any authenticated member)
|
||||
//! - `GET /api/relay/members/me` — get own membership record (or 404)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
|
||||
use sprout_auth::Scope;
|
||||
|
||||
use super::{extract_auth_context, extract_auth_context_inner, internal_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Enforcement ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Enforce relay membership for a pubkey.
|
||||
///
|
||||
/// - If `config.require_relay_membership` is false → always Ok (no-op).
|
||||
/// - If enabled → checks `relay_members` table. Returns 403 if not a member.
|
||||
///
|
||||
/// `pubkey_bytes` is the 32-byte compressed pubkey; it is hex-encoded before
|
||||
/// the DB lookup (the `relay_members` table stores 64-char hex strings).
|
||||
pub async fn enforce_relay_membership(
|
||||
state: &AppState,
|
||||
pubkey_bytes: &[u8],
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
if !state.config.require_relay_membership {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
let is_member = state
|
||||
.db
|
||||
.is_relay_member(&pubkey_hex)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("relay membership check failed: {e}")))?;
|
||||
|
||||
if is_member {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "relay_membership_required",
|
||||
"message": "You must be a relay member to access this relay"
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ── REST read handlers ────────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /api/relay/members` — list all relay members.
|
||||
///
|
||||
/// Any authenticated relay member can call this. The membership gate is
|
||||
/// enforced by `extract_auth_context` (which wraps the inner extractor).
|
||||
pub async fn list_relay_members(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
// extract_auth_context enforces relay membership
|
||||
let ctx = extract_auth_context(&headers, &state).await?;
|
||||
|
||||
// Require at least UsersRead scope to enumerate relay members.
|
||||
// Empty scopes means NIP-98 auth (implicit full access) — skip the check.
|
||||
if !ctx.scopes.is_empty()
|
||||
&& !ctx.scopes.contains(&Scope::UsersRead)
|
||||
&& !ctx.scopes.contains(&Scope::AdminUsers)
|
||||
{
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "insufficient_scope",
|
||||
"message": "Requires users:read or admin:users scope"
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let members = state
|
||||
.db
|
||||
.list_relay_members()
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("list relay members: {e}")))?;
|
||||
|
||||
let items: Vec<serde_json::Value> = members
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"pubkey": m.pubkey,
|
||||
"role": m.role,
|
||||
"added_by": m.added_by,
|
||||
"created_at": m.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "members": items })))
|
||||
}
|
||||
|
||||
/// `GET /api/relay/members/me` — get own membership record.
|
||||
///
|
||||
/// Uses the inner auth extractor (no membership gate) so non-members
|
||||
/// get a proper 404 instead of 403.
|
||||
pub async fn get_my_relay_membership(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let ctx = extract_auth_context_inner(&headers, &state).await?;
|
||||
let pubkey_hex = hex::encode(&ctx.pubkey_bytes);
|
||||
|
||||
let member = state
|
||||
.db
|
||||
.get_relay_member(&pubkey_hex)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("get relay member: {e}")))?;
|
||||
|
||||
match member {
|
||||
Some(m) => Ok(Json(serde_json::json!({
|
||||
"pubkey": m.pubkey,
|
||||
"role": m.role,
|
||||
"added_by": m.added_by,
|
||||
"created_at": m.created_at.to_rfc3339(),
|
||||
}))),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": "not_a_member",
|
||||
"message": "You are not a relay member"
|
||||
})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,9 @@ pub async fn post_tokens(
|
||||
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
|
||||
tracing::warn!("ensure_user failed for NIP-98 pubkey: {e}");
|
||||
}
|
||||
// NIP-98 path builds auth context directly — enforce membership here.
|
||||
// Bearer/JWT paths go through extract_auth_context which already checks.
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes).await?;
|
||||
super::RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes,
|
||||
|
||||
@@ -142,6 +142,22 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
let parent_channel_id = auth_msg.parent_channel_id;
|
||||
|
||||
// ── Relay membership gate (NIP-43) ────────────────────────────────────────
|
||||
if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied");
|
||||
let _ = ws_send
|
||||
.send(WsMessage::Text(
|
||||
serde_json::json!({"type": "error", "message": "restricted: not a relay member"})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 3: membership check / auto-add ───────────────────────────────────
|
||||
if let Err(e) = ensure_membership(&state, channel_id, &pubkey_bytes, parent_channel_id).await {
|
||||
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}");
|
||||
|
||||
@@ -62,6 +62,18 @@ pub struct Config {
|
||||
/// API tokens or Okta JWTs bypass the allowlist entirely.
|
||||
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
|
||||
pub pubkey_allowlist_enabled: bool,
|
||||
|
||||
/// When true, every authenticated request must also pass a relay-level
|
||||
/// membership check against the `relay_members` table.
|
||||
/// When false (default), the check is a no-op and all authenticated callers
|
||||
/// are permitted regardless of auth method (API token, JWT, NIP-42).
|
||||
pub require_relay_membership: bool,
|
||||
|
||||
/// Optional hex-encoded pubkey of the relay owner.
|
||||
/// When set, this pubkey is automatically bootstrapped into `relay_members`
|
||||
/// with the `owner` role on first startup.
|
||||
pub relay_owner_pubkey: Option<String>,
|
||||
|
||||
/// Media storage configuration (S3/MinIO).
|
||||
pub media: sprout_media::MediaConfig,
|
||||
|
||||
@@ -133,6 +145,30 @@ impl Config {
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
let require_relay_membership = std::env::var("SPROUT_REQUIRE_RELAY_MEMBERSHIP")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
// Note: intentionally not prefixed with SPROUT_ — this is a relay-identity
|
||||
// config that may be shared across multiple services (e.g., ACP agent).
|
||||
let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| {
|
||||
// Must be exactly 64 lowercase hex characters (32-byte pubkey).
|
||||
let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit());
|
||||
if valid {
|
||||
Some(s)
|
||||
} else {
|
||||
warn!(
|
||||
"RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — ignoring. \
|
||||
Got: {s:?}"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let mut auth = sprout_auth::AuthConfig::default();
|
||||
auth.okta.require_token = require_auth_token;
|
||||
|
||||
@@ -284,6 +320,8 @@ impl Config {
|
||||
health_port,
|
||||
metrics_port,
|
||||
pubkey_allowlist_enabled,
|
||||
require_relay_membership,
|
||||
relay_owner_pubkey,
|
||||
media,
|
||||
ephemeral_ttl_override,
|
||||
git_repo_path,
|
||||
@@ -317,6 +355,14 @@ mod tests {
|
||||
!config.pubkey_allowlist_enabled,
|
||||
"pubkey_allowlist_enabled should default to false"
|
||||
);
|
||||
assert!(
|
||||
!config.require_relay_membership,
|
||||
"require_relay_membership should default to false"
|
||||
);
|
||||
assert!(
|
||||
config.relay_owner_pubkey.is_none(),
|
||||
"relay_owner_pubkey should default to None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,6 +9,51 @@ use crate::connection::{AuthState, ConnectionState};
|
||||
use crate::protocol::RelayMessage;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Check relay membership for a pubkey during NIP-42 auth.
|
||||
///
|
||||
/// Returns `true` if the pubkey is a relay member (or if membership enforcement
|
||||
/// is disabled). Returns `false` and sends a rejection message if not a member.
|
||||
async fn enforce_ws_relay_membership(
|
||||
state: &AppState,
|
||||
conn: &Arc<ConnectionState>,
|
||||
conn_id: uuid::Uuid,
|
||||
pubkey: &nostr::PublicKey,
|
||||
event_id_hex: &str,
|
||||
) -> bool {
|
||||
if !state.config.require_relay_membership {
|
||||
return true;
|
||||
}
|
||||
|
||||
let pubkey_hex = pubkey.to_hex();
|
||||
let is_member = match state.db.is_relay_member(&pubkey_hex).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
conn_id = %conn_id,
|
||||
pubkey = %pubkey_hex,
|
||||
error = %e,
|
||||
"relay membership check failed, denying (fail-closed)"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !is_member {
|
||||
warn!(conn_id = %conn_id, pubkey = %pubkey_hex, "not a relay member");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
event_id_hex,
|
||||
false,
|
||||
"restricted: not a relay member",
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn verify_api_token_nip42_binding(
|
||||
event: &nostr::Event,
|
||||
challenge: &str,
|
||||
@@ -170,6 +215,14 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
};
|
||||
// API token users have already proven authorization via their token —
|
||||
// the pubkey allowlist does not apply here.
|
||||
|
||||
// Relay membership gate (NIP-43) — applies to ALL auth methods.
|
||||
if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
|
||||
state
|
||||
.conn_manager
|
||||
@@ -225,6 +278,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Relay membership gate (NIP-43) — applies to ALL auth methods.
|
||||
if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex).await {
|
||||
return;
|
||||
}
|
||||
|
||||
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
|
||||
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
|
||||
state
|
||||
|
||||
@@ -30,7 +30,7 @@ fn reject(reason: &'static str) {
|
||||
fn bounded_kind_label(kind: u32) -> String {
|
||||
match kind {
|
||||
0..=9 | 1059 | 1063 => kind.to_string(),
|
||||
9000..=9022 | 9100 | 9110 | 9900 => kind.to_string(),
|
||||
9000..=9022 | 9030..=9032 | 9100 | 9110 | 9900 => kind.to_string(),
|
||||
20000..=29999 => kind.to_string(),
|
||||
30023 | 30315 | 39000..=39003 => kind.to_string(),
|
||||
40002..=40100 => kind.to_string(),
|
||||
|
||||
@@ -12,9 +12,9 @@ use uuid::Uuid;
|
||||
use nostr::Event;
|
||||
use sprout_auth::Scope;
|
||||
use sprout_core::kind::{
|
||||
event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
|
||||
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
|
||||
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
|
||||
event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AUTH, KIND_CANVAS,
|
||||
KIND_CONTACT_LIST, KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE,
|
||||
KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
|
||||
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
|
||||
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
|
||||
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE,
|
||||
@@ -22,10 +22,11 @@ use sprout_core::kind::{
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP,
|
||||
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
|
||||
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
|
||||
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
|
||||
KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
|
||||
KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
|
||||
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
|
||||
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
|
||||
};
|
||||
use sprout_core::verification::verify_event;
|
||||
|
||||
@@ -173,6 +174,13 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
KIND_NIP29_PUT_USER | KIND_NIP29_REMOVE_USER | KIND_NIP29_DELETE_GROUP => {
|
||||
Ok(Scope::AdminChannels)
|
||||
}
|
||||
// NIP-43: relay membership admin commands (9030–9032)
|
||||
k if k == RELAY_ADMIN_ADD_MEMBER
|
||||
|| k == RELAY_ADMIN_REMOVE_MEMBER
|
||||
|| k == RELAY_ADMIN_CHANGE_ROLE =>
|
||||
{
|
||||
Ok(Scope::AdminUsers)
|
||||
}
|
||||
KIND_NIP29_EDIT_METADATA => {
|
||||
// kind:9002 scope split: archived tag → AdminChannels, else ChannelsWrite
|
||||
let has_archived = event
|
||||
@@ -186,7 +194,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
}
|
||||
}
|
||||
KIND_NIP29_CREATE_GROUP | KIND_CANVAS => Ok(Scope::ChannelsWrite),
|
||||
KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => Ok(Scope::ChannelsRead),
|
||||
KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST => {
|
||||
Ok(Scope::ChannelsRead)
|
||||
}
|
||||
// Huddle lifecycle events + guidelines
|
||||
KIND_HUDDLE_STARTED
|
||||
| KIND_HUDDLE_PARTICIPANT_JOINED
|
||||
@@ -303,6 +313,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
|
||||
| KIND_GIT_STATUS_MERGED
|
||||
| KIND_GIT_STATUS_CLOSED
|
||||
| KIND_GIT_STATUS_DRAFT
|
||||
// NIP-43: relay admin commands and leave requests are global — they
|
||||
// must never be channel-scoped, even if the event carries a stray `h` tag.
|
||||
| RELAY_ADMIN_ADD_MEMBER
|
||||
| RELAY_ADMIN_REMOVE_MEMBER
|
||||
| RELAY_ADMIN_CHANGE_ROLE
|
||||
| KIND_NIP43_LEAVE_REQUEST
|
||||
)
|
||||
}
|
||||
|
||||
@@ -840,6 +856,29 @@ pub async fn ingest_event(
|
||||
Ok(scope) => scope,
|
||||
Err(msg) => return Err(IngestError::Rejected(msg.into())),
|
||||
};
|
||||
// NIP-43: relay admin commands must NOT be submitted via proxy — they require
|
||||
// the actual admin's signed event for authorization.
|
||||
if auth.has_proxy_scope() && is_relay_admin_kind(event.kind.as_u16() as u32) {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: relay admin commands cannot be submitted via proxy".into(),
|
||||
));
|
||||
}
|
||||
// NIP-43: relay admin commands are global — channel-scoped tokens cannot
|
||||
// issue them even if the event has no `h` tag (is_global_only_kind strips
|
||||
// channel_id, but we still need to reject the token itself).
|
||||
if is_relay_admin_kind(kind_u32) && auth.channel_ids().is_some() {
|
||||
return Err(IngestError::AuthFailed(
|
||||
"restricted: relay admin commands require a global token, not a channel-scoped token"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// NIP-43: leave requests are also global — channel-scoped tokens cannot
|
||||
// issue them.
|
||||
if kind_u32 == KIND_NIP43_LEAVE_REQUEST && auth.channel_ids().is_some() {
|
||||
return Err(IngestError::AuthFailed(
|
||||
"restricted: leave requests require a global token".into(),
|
||||
));
|
||||
}
|
||||
if !auth.has_proxy_scope() && !auth.scopes().contains(&required) {
|
||||
return Err(IngestError::AuthFailed(format!(
|
||||
"restricted: insufficient scope (need {})",
|
||||
@@ -949,6 +988,103 @@ pub async fn ingest_event(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9a. Relay admin commands (kinds 9030–9032) ───────────────────────
|
||||
// Handled directly — these mutate relay_members and do NOT get stored.
|
||||
if is_relay_admin_kind(event.kind.as_u16() as u32) {
|
||||
crate::handlers::relay_admin::handle_relay_admin_event(state, &event)
|
||||
.await
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
return Ok(IngestResult {
|
||||
event_id: event_id_hex,
|
||||
accepted: true,
|
||||
message: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9b. NIP-43 leave request (kind 28936) ────────────────────────────
|
||||
// Handled directly — removes the sender from relay_members. NOT stored.
|
||||
if kind_u32 == KIND_NIP43_LEAVE_REQUEST {
|
||||
if !state.config.require_relay_membership {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: relay membership is not enabled".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Freshness check: reject events outside ±120s of now (same as admin commands).
|
||||
{
|
||||
let event_ts = event.created_at.as_u64() as i64;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if (event_ts - now).abs() > 120 {
|
||||
return Err(IngestError::Rejected(format!(
|
||||
"invalid: leave request timestamp out of range (delta={}s, max ±120s)",
|
||||
event_ts - now
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-43 spec: "This event MUST include a NIP-70 `-` tag."
|
||||
let has_protected_tag = event
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("-"));
|
||||
if !has_protected_tag {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: leave request must include NIP-70 protected event tag [\"-\"]".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let sender_hex = event.pubkey.to_hex();
|
||||
|
||||
// remove_relay_member handles both the NotFound and IsOwner cases atomically.
|
||||
let remove_result = state
|
||||
.db
|
||||
.remove_relay_member(&sender_hex)
|
||||
.await
|
||||
.map_err(|e| IngestError::Internal(format!("database error: {e}")))?;
|
||||
|
||||
match remove_result {
|
||||
sprout_db::relay_members::RemoveResult::Removed => {}
|
||||
sprout_db::relay_members::RemoveResult::NotFound => {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: you are not a relay member".into(),
|
||||
));
|
||||
}
|
||||
sprout_db::relay_members::RemoveResult::IsOwner => {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: relay owner cannot leave".into(),
|
||||
));
|
||||
}
|
||||
sprout_db::relay_members::RemoveResult::RoleMismatch => {
|
||||
// remove_relay_member (no role filter) never returns RoleMismatch —
|
||||
// this arm is unreachable but exhaustiveness requires it.
|
||||
return Err(IngestError::Internal(
|
||||
"unexpected RoleMismatch from remove_relay_member".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Publish NIP-43 announcements — fire-and-forget.
|
||||
if let Err(e) =
|
||||
crate::handlers::side_effects::publish_nip43_member_removed(state, &sender_hex).await
|
||||
{
|
||||
warn!(error = %e, "failed to publish NIP-43 member removed event");
|
||||
}
|
||||
if let Err(e) = crate::handlers::side_effects::publish_nip43_membership_list(state).await {
|
||||
warn!(error = %e, "failed to publish NIP-43 membership list");
|
||||
}
|
||||
|
||||
info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request");
|
||||
|
||||
return Ok(IngestResult {
|
||||
event_id: event_id_hex,
|
||||
accepted: true,
|
||||
message: "info: you have left this relay".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Admin validation (kinds 9000–9022) ────────────────────────────
|
||||
if crate::handlers::side_effects::is_admin_kind(kind_u32) {
|
||||
crate::handlers::side_effects::validate_admin_event(kind_u32, &event, state)
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod close;
|
||||
pub mod event;
|
||||
/// Transport-neutral event ingestion pipeline.
|
||||
pub mod ingest;
|
||||
/// NIP-43 relay membership admin command handler (kinds 9030–9032).
|
||||
pub mod relay_admin;
|
||||
pub mod req;
|
||||
/// NIP-29 and NIP-25 side-effect handlers.
|
||||
pub mod side_effects;
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! NIP-43 relay membership admin command handler (kinds 9030–9032).
|
||||
//!
|
||||
//! These events are processed directly — they mutate the `relay_members` table
|
||||
//! and return without being stored as regular Nostr events.
|
||||
//!
|
||||
//! ## Permission matrix
|
||||
//!
|
||||
//! | Kind | Operation | Required sender role |
|
||||
//! |------|-----------------|----------------------|
|
||||
//! | 9030 | Add member | admin or owner |
|
||||
//! | 9031 | Remove member | admin or owner |
|
||||
//! | 9032 | Change role | owner only |
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nostr::Event;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use sprout_core::kind::{
|
||||
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
|
||||
};
|
||||
use sprout_db::relay_members::RemoveResult;
|
||||
|
||||
use crate::handlers::side_effects::{
|
||||
publish_nip43_member_added, publish_nip43_member_removed, publish_nip43_membership_list,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Tag extraction helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the hex pubkey from the first `p` tag, returning it as a `String`.
|
||||
fn extract_p_tag_hex(event: &Event) -> Option<String> {
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some("p") {
|
||||
if let Some(val) = parts.get(1).map(|s| s.as_str()) {
|
||||
// Must be exactly 64 hex chars (uncompressed pubkey representation).
|
||||
if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the value of the first tag with the given name.
|
||||
fn extract_tag_value(event: &Event, name: &str) -> Option<String> {
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some(name) {
|
||||
return parts.get(1).map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Public handler ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Validate and execute a relay admin command (kinds 9030–9032).
|
||||
///
|
||||
/// The handler:
|
||||
/// 1. Extracts the target pubkey from the `["p", ...]` tag.
|
||||
/// 2. Extracts the role from the `["role", ...]` tag (kinds 9030 and 9032).
|
||||
/// 3. Looks up the sender's current role in `relay_members`.
|
||||
/// 4. Enforces the permission matrix.
|
||||
/// 5. Applies the change via the DB.
|
||||
///
|
||||
/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is a
|
||||
/// human-readable rejection reason — on any validation failure.
|
||||
pub async fn handle_relay_admin_event(state: &Arc<AppState>, event: &Event) -> Result<(), String> {
|
||||
let kind = event.kind.as_u16() as u32;
|
||||
let sender_hex = event.pubkey.to_hex();
|
||||
|
||||
// ── Replay protection: reject events outside ±120s of now ────────────
|
||||
// This mirrors the NIP-42 auth event freshness check and prevents replay
|
||||
// of captured admin commands. The window is intentionally tight — admin
|
||||
// events should be freshly signed.
|
||||
{
|
||||
let event_ts = event.created_at.as_u64() as i64;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if (event_ts - now).abs() > 120 {
|
||||
return Err(format!(
|
||||
"event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)",
|
||||
event_ts - now
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract target pubkey ─────────────────────────────────────────────
|
||||
let target_hex = extract_p_tag_hex(event)
|
||||
.ok_or_else(|| "missing or invalid p tag".to_string())?
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// ── Look up sender's relay role ───────────────────────────────────────
|
||||
let sender_member = state
|
||||
.db
|
||||
.get_relay_member(&sender_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
|
||||
let sender_role = sender_member
|
||||
.as_ref()
|
||||
.map(|m| m.role.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// ── Dispatch by kind ──────────────────────────────────────────────────
|
||||
match kind {
|
||||
// kind:9030 — Add relay member
|
||||
k if k == RELAY_ADMIN_ADD_MEMBER => {
|
||||
// Sender must be admin or owner.
|
||||
if sender_role != "admin" && sender_role != "owner" {
|
||||
return Err("actor not authorized: must be admin or owner".to_string());
|
||||
}
|
||||
|
||||
// Default role is "member" when no role tag is present.
|
||||
let role = extract_tag_value(event, "role").unwrap_or_else(|| "member".to_string());
|
||||
|
||||
// Owners can add admins or members; admins can only add members.
|
||||
if role == "owner" {
|
||||
return Err("invalid role: use kind:9032 to promote to owner".to_string());
|
||||
}
|
||||
if role == "admin" && sender_role != "owner" {
|
||||
return Err("actor not authorized: only owner can grant admin role".to_string());
|
||||
}
|
||||
if role != "admin" && role != "member" {
|
||||
return Err(format!("invalid role: {role}"));
|
||||
}
|
||||
|
||||
// Note: idempotent — if target already exists at any role, this is a
|
||||
// silent no-op. The existing role is NOT overwritten. Use kind:9032
|
||||
// to change an existing member's role.
|
||||
let was_inserted = state
|
||||
.db
|
||||
.add_relay_member(&target_hex, &role, Some(&sender_hex))
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
|
||||
info!(
|
||||
sender = %sender_hex,
|
||||
target = %target_hex,
|
||||
role = %role,
|
||||
was_inserted,
|
||||
"relay member add attempted"
|
||||
);
|
||||
|
||||
// Only publish NIP-43 announcements when the row was actually inserted —
|
||||
// skip on no-op re-adds to avoid spurious kind:8000 events.
|
||||
if was_inserted {
|
||||
if let Err(e) = publish_nip43_member_added(state, &target_hex).await {
|
||||
warn!(error = %e, "failed to publish NIP-43 member added event");
|
||||
}
|
||||
if let Err(e) = publish_nip43_membership_list(state).await {
|
||||
warn!(error = %e, "failed to publish NIP-43 membership list");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// kind:9031 — Remove relay member
|
||||
k if k == RELAY_ADMIN_REMOVE_MEMBER => {
|
||||
// Sender must be admin or owner.
|
||||
if sender_role != "admin" && sender_role != "owner" {
|
||||
return Err("actor not authorized: must be admin or owner".to_string());
|
||||
}
|
||||
|
||||
// Cannot remove yourself.
|
||||
if target_hex == sender_hex {
|
||||
return Err("cannot remove yourself".to_string());
|
||||
}
|
||||
|
||||
// Dispatch removal by sender role:
|
||||
// - Admins: atomic conditional delete, only removes 'member' targets.
|
||||
// This eliminates the TOCTOU race where the target could be promoted
|
||||
// between a prior role read and the delete.
|
||||
// - Owners: can remove admins and members, not other owners.
|
||||
let remove_result = if sender_role == "admin" {
|
||||
state
|
||||
.db
|
||||
.remove_relay_member_if_role(&target_hex, "member")
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?
|
||||
} else {
|
||||
// Owner path — atomic delete that refuses to remove other owners.
|
||||
state
|
||||
.db
|
||||
.remove_relay_member(&target_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?
|
||||
};
|
||||
|
||||
match remove_result {
|
||||
RemoveResult::Removed => {}
|
||||
RemoveResult::IsOwner => {
|
||||
return Err("cannot remove the relay owner".to_string());
|
||||
}
|
||||
RemoveResult::NotFound => {
|
||||
return Err(format!("member not found: {target_hex}"));
|
||||
}
|
||||
RemoveResult::RoleMismatch => {
|
||||
return Err("actor not authorized: admins can only remove members".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
sender = %sender_hex,
|
||||
target = %target_hex,
|
||||
"relay member removed"
|
||||
);
|
||||
|
||||
if let Err(e) = publish_nip43_member_removed(state, &target_hex).await {
|
||||
warn!(error = %e, "failed to publish NIP-43 member removed event");
|
||||
}
|
||||
if let Err(e) = publish_nip43_membership_list(state).await {
|
||||
warn!(error = %e, "failed to publish NIP-43 membership list");
|
||||
}
|
||||
}
|
||||
|
||||
// kind:9032 — Change relay member role
|
||||
k if k == RELAY_ADMIN_CHANGE_ROLE => {
|
||||
// Only owners may change roles.
|
||||
if sender_role != "owner" {
|
||||
return Err("actor not authorized: must be owner".to_string());
|
||||
}
|
||||
|
||||
// Cannot change your own role.
|
||||
if target_hex == sender_hex {
|
||||
return Err("cannot change your own role".to_string());
|
||||
}
|
||||
|
||||
let new_role =
|
||||
extract_tag_value(event, "role").ok_or_else(|| "missing role tag".to_string())?;
|
||||
|
||||
// DESIGN: Ownership transfer via kind:9032 is intentionally blocked.
|
||||
// Transferring ownership is a high-risk operation that could permanently
|
||||
// lock out the current owner. Use RELAY_OWNER_PUBKEY config to change ownership.
|
||||
if new_role == "owner" {
|
||||
return Err("cannot set role to owner".to_string());
|
||||
}
|
||||
if new_role != "admin" && new_role != "member" {
|
||||
return Err(format!("invalid role: {new_role}"));
|
||||
}
|
||||
|
||||
let updated = state
|
||||
.db
|
||||
.update_relay_member_role(&target_hex, &new_role)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
|
||||
if !updated {
|
||||
// Distinguish "owner (protected)" from "doesn't exist"
|
||||
let exists = state
|
||||
.db
|
||||
.get_relay_member(&target_hex)
|
||||
.await
|
||||
.map_err(|e| format!("database error: {e}"))?;
|
||||
return Err(if exists.is_some() {
|
||||
"cannot change the relay owner's role".to_string()
|
||||
} else {
|
||||
format!("member not found: {target_hex}")
|
||||
});
|
||||
}
|
||||
|
||||
info!(
|
||||
sender = %sender_hex,
|
||||
target = %target_hex,
|
||||
new_role = %new_role,
|
||||
"relay member role changed"
|
||||
);
|
||||
|
||||
if let Err(e) = publish_nip43_membership_list(state).await {
|
||||
warn!(error = %e, "failed to publish NIP-43 membership list");
|
||||
}
|
||||
}
|
||||
|
||||
other => {
|
||||
return Err(format!("unexpected relay admin kind: {other}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
/// Build a minimal signed Event with the given kind and tags.
|
||||
/// The pubkey will be randomly generated — sufficient for tag extraction tests.
|
||||
fn make_test_event(kind: u16, tags: Vec<Vec<&'static str>>) -> Event {
|
||||
let keys = Keys::generate();
|
||||
let nostr_tags: Vec<Tag> = tags
|
||||
.into_iter()
|
||||
.map(|parts| Tag::parse(&parts).expect("valid tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::from(kind), "", nostr_tags)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("signing failed")
|
||||
}
|
||||
|
||||
// ── extract_p_tag_hex ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_p_tag_valid_hex() {
|
||||
let hex = "a".repeat(64);
|
||||
let event = make_test_event(
|
||||
9030,
|
||||
vec![vec!["p", Box::leak(hex.clone().into_boxed_str())]],
|
||||
);
|
||||
assert_eq!(extract_p_tag_hex(&event), Some(hex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_p_tag_rejects_short_hex() {
|
||||
let event = make_test_event(9030, vec![vec!["p", "abcd"]]);
|
||||
assert_eq!(extract_p_tag_hex(&event), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_p_tag_rejects_non_hex() {
|
||||
// 'g' is not a hex digit
|
||||
let event = make_test_event(
|
||||
9030,
|
||||
vec![vec![
|
||||
"p",
|
||||
"gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
|
||||
]],
|
||||
);
|
||||
assert_eq!(extract_p_tag_hex(&event), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_p_tag_missing() {
|
||||
let event = make_test_event(9030, vec![]);
|
||||
assert_eq!(extract_p_tag_hex(&event), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_p_tag_ignores_non_p_tags() {
|
||||
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
|
||||
assert_eq!(extract_p_tag_hex(&event), None);
|
||||
}
|
||||
|
||||
// ── extract_tag_value ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_tag_value_found() {
|
||||
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
|
||||
assert_eq!(extract_tag_value(&event, "role"), Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tag_value_missing() {
|
||||
let event = make_test_event(9030, vec![]);
|
||||
assert_eq!(extract_tag_value(&event, "role"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tag_value_returns_first_match() {
|
||||
let event = make_test_event(9030, vec![vec!["role", "member"], vec!["role", "admin"]]);
|
||||
assert_eq!(
|
||||
extract_tag_value(&event, "role"),
|
||||
Some("member".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tag_value_wrong_name() {
|
||||
let event = make_test_event(9030, vec![vec!["role", "admin"]]);
|
||||
assert_eq!(extract_tag_value(&event, "p"), None);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
||||
use sprout_core::kind::{
|
||||
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
|
||||
KIND_NIP29_GROUP_METADATA, KIND_REACTION,
|
||||
KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
|
||||
};
|
||||
use sprout_db::channel::MemberRole;
|
||||
|
||||
@@ -1640,3 +1640,118 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> a
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── NIP-43 relay-level membership announcement events ────────────────────────
|
||||
|
||||
/// Publish a kind:13534 relay membership list event (NIP-43).
|
||||
///
|
||||
/// Queries all current relay members and emits a relay-signed, NIP-70-protected
|
||||
/// addressable event listing every member pubkey. Replaces any previous list.
|
||||
pub async fn publish_nip43_membership_list(state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
let members = state.db.list_relay_members().await?;
|
||||
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
|
||||
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
|
||||
|
||||
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
|
||||
tags.push(Tag::parse(&["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
|
||||
|
||||
for member in &members {
|
||||
tags.push(
|
||||
Tag::parse(&["member", &member.pubkey])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "", tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
|
||||
|
||||
// NOTE: kind 13534 is technically a regular event (not in the NIP-16 replaceable
|
||||
// range), but we intentionally use replace_addressable_event to get replacement
|
||||
// semantics — only the latest membership snapshot matters. This function keys on
|
||||
// (kind, pubkey, channel_id) and atomically replaces older events, which is exactly
|
||||
// what Pyramid (the reference NIP-43 implementation) does with store.ReplaceEvent().
|
||||
let (stored, was_inserted) = state.db.replace_addressable_event(&event, None).await?;
|
||||
if was_inserted {
|
||||
dispatch_persistent_event(
|
||||
state,
|
||||
&stored,
|
||||
KIND_NIP43_MEMBERSHIP_LIST,
|
||||
&relay_pubkey_hex,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
info!(
|
||||
member_count = members.len(),
|
||||
"NIP-43 membership list published"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared helper: publish a NIP-43 membership delta event (kind 8000 or 8001).
|
||||
///
|
||||
/// Signs a relay event with `["-"]` (NIP-70) + `["p", target]` tags, stores it
|
||||
/// globally, and fans out to matching subscribers.
|
||||
async fn publish_nip43_delta(
|
||||
state: &Arc<AppState>,
|
||||
kind: u16,
|
||||
target_pubkey_hex: &str,
|
||||
label: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
|
||||
|
||||
let tags = vec![
|
||||
Tag::parse(&["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?,
|
||||
Tag::parse(&["p", target_pubkey_hex])
|
||||
.map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?,
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(kind), "", tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?;
|
||||
|
||||
let (stored, was_inserted) = state.db.insert_event(&event, None).await?;
|
||||
if !was_inserted {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matches = state.sub_registry.fan_out(&stored);
|
||||
if !matches.is_empty() {
|
||||
let event_json = match serde_json::to_string(&stored.event) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!("failed to serialize kind:{kind} for fan-out: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
for (target_conn_id, sub_id) in &matches {
|
||||
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
|
||||
state.conn_manager.send_to(*target_conn_id, msg);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
target = %target_pubkey_hex,
|
||||
relay = %relay_pubkey_hex,
|
||||
"NIP-43 {label} event published"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a kind:8000 relay member-added announcement event (NIP-43).
|
||||
pub async fn publish_nip43_member_added(
|
||||
state: &Arc<AppState>,
|
||||
target_pubkey_hex: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
publish_nip43_delta(state, 8000, target_pubkey_hex, "member-added").await
|
||||
}
|
||||
|
||||
/// Publish a kind:8001 relay member-removed announcement event (NIP-43).
|
||||
pub async fn publish_nip43_member_removed(
|
||||
state: &Arc<AppState>,
|
||||
target_pubkey_hex: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
publish_nip43_delta(state, 8001, target_pubkey_hex, "member-removed").await
|
||||
}
|
||||
|
||||
@@ -59,6 +59,73 @@ async fn main() -> anyhow::Result<()> {
|
||||
error!("Failed to ensure partitions: {e}");
|
||||
}
|
||||
|
||||
// NIP-43: if membership enforcement is on, a valid owner pubkey is required.
|
||||
// config.rs already strips invalid values with a warning; catch the resulting
|
||||
// None here so we fail fast with a clear message rather than starting a relay
|
||||
// that no one can administer.
|
||||
if config.require_relay_membership && config.relay_owner_pubkey.is_none() {
|
||||
error!(
|
||||
"SPROUT_REQUIRE_RELAY_MEMBERSHIP=true but RELAY_OWNER_PUBKEY is not set or invalid. \
|
||||
Set RELAY_OWNER_PUBKEY to a valid 64-char hex pubkey."
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"RELAY_OWNER_PUBKEY required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true"
|
||||
));
|
||||
}
|
||||
|
||||
// NIP-43: relay membership requires a stable signing key.
|
||||
// Check this before any DB mutations so we fail fast — no point backfilling
|
||||
// or bootstrapping if we'll reject the config anyway.
|
||||
if config.require_relay_membership && config.relay_private_key.is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"SPROUT_RELAY_PRIVATE_KEY is required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true. \
|
||||
NIP-43 events signed with an ephemeral key become unverifiable after restart."
|
||||
));
|
||||
}
|
||||
|
||||
// NIP-43: migrate any existing pubkey_allowlist entries to relay_members.
|
||||
// Idempotent — safe to run every startup. Must run before bootstrap_owner
|
||||
// so that existing allowlist users become relay members before the owner
|
||||
// is promoted (otherwise enabling membership locks everyone out).
|
||||
match db.backfill_from_allowlist().await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"),
|
||||
Err(e) => {
|
||||
if config.require_relay_membership {
|
||||
error!(
|
||||
"Fatal: failed to backfill allowlist with membership enforcement enabled: {e}"
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to backfill pubkey_allowlist (required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
|
||||
));
|
||||
} else {
|
||||
error!("Failed to backfill pubkey_allowlist (non-fatal): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-43: ensure the configured relay owner always holds the owner role.
|
||||
if let Some(ref owner_pubkey) = config.relay_owner_pubkey {
|
||||
match db.bootstrap_owner(owner_pubkey).await {
|
||||
Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"),
|
||||
Err(e) => {
|
||||
if config.require_relay_membership {
|
||||
// Membership enforcement is on — a missing owner means no one
|
||||
// can administer the relay. Fail fast rather than silently start
|
||||
// in a broken state.
|
||||
error!("Fatal: failed to bootstrap relay owner with membership enforcement enabled: {e}");
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to bootstrap relay owner (required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
|
||||
));
|
||||
} else {
|
||||
error!(
|
||||
"Failed to bootstrap relay owner (non-fatal, membership not required): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-33: backfill d_tag for any existing parameterized replaceable events
|
||||
// that predate the column addition. Idempotent — no-ops when fully populated.
|
||||
match db.backfill_d_tags().await {
|
||||
@@ -144,6 +211,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
let state = Arc::new(app_state);
|
||||
|
||||
// NIP-43: publish the initial membership list on startup so clients can
|
||||
// REQ kind:13534 immediately without waiting for the next membership change.
|
||||
if config.require_relay_membership {
|
||||
let startup_state = Arc::clone(&state);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
sprout_relay::handlers::side_effects::publish_nip43_membership_list(&startup_state)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to publish initial NIP-43 membership list on startup");
|
||||
} else {
|
||||
tracing::info!("NIP-43 membership list published on startup");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire the action sink — must happen after AppState (which creates
|
||||
// sub_registry, conn_manager) and before the cron loop starts.
|
||||
let action_sink = Arc::new(sprout_relay::workflow_sink::RelayActionSink::new(&state));
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::connection::MAX_FRAME_BYTES;
|
||||
/// NIPs supported by this relay, advertised in the NIP-11 document.
|
||||
/// Kept as a module-level constant so tests can verify it without constructing
|
||||
/// a full `Config` (which reads env vars and races with config.rs tests).
|
||||
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50];
|
||||
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 43, 50];
|
||||
|
||||
/// Relay information document served at `GET /` with `Accept: application/nostr+json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -28,6 +28,9 @@ pub struct RelayInfo {
|
||||
pub version: String,
|
||||
/// Protocol and resource limits advertised to clients.
|
||||
pub limitation: Option<RelayLimitation>,
|
||||
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
|
||||
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
|
||||
pub relay_self: Option<String>,
|
||||
}
|
||||
|
||||
/// Protocol and resource limits advertised in the NIP-11 document.
|
||||
@@ -55,7 +58,10 @@ pub struct RelayLimitation {
|
||||
|
||||
impl RelayInfo {
|
||||
/// Builds a `RelayInfo` document from the relay's runtime config.
|
||||
pub fn from_config(config: &crate::config::Config) -> Self {
|
||||
///
|
||||
/// `relay_pubkey` is the relay's own signing pubkey (hex), advertised as the
|
||||
/// NIP-11 `self` field for NIP-43 membership verification.
|
||||
pub fn from_config(config: &crate::config::Config, relay_pubkey: Option<&str>) -> Self {
|
||||
Self {
|
||||
name: "Sprout Relay".to_string(),
|
||||
description: "Sprout — private team communication relay".to_string(),
|
||||
@@ -75,6 +81,7 @@ impl RelayInfo {
|
||||
payment_required: false,
|
||||
restricted_writes: true,
|
||||
}),
|
||||
relay_self: relay_pubkey.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +90,17 @@ impl RelayInfo {
|
||||
pub async fn relay_info_handler(
|
||||
axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
|
||||
) -> axum::response::Json<RelayInfo> {
|
||||
axum::response::Json(RelayInfo::from_config(&state.config))
|
||||
// Only advertise the NIP-11 `self` field when a stable relay key is configured.
|
||||
// Ephemeral (auto-generated) keys change on restart, making signed events unverifiable.
|
||||
let relay_pubkey = if state.config.relay_private_key.is_some() {
|
||||
Some(state.relay_keypair.public_key().to_hex())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
axum::response::Json(RelayInfo::from_config(
|
||||
&state.config,
|
||||
relay_pubkey.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -116,6 +116,15 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
"/huddle/{channel_id}/audio",
|
||||
get(audio::handler::ws_audio_handler),
|
||||
)
|
||||
// Relay membership routes (NIP-43)
|
||||
.route(
|
||||
"/api/relay/members",
|
||||
get(api::relay_members::list_relay_members),
|
||||
)
|
||||
.route(
|
||||
"/api/relay/members/me",
|
||||
get(api::relay_members::get_my_relay_membership),
|
||||
)
|
||||
// Membership routes
|
||||
.route("/api/channels/{channel_id}/members", get(api::list_members))
|
||||
// Channel detail + metadata routes
|
||||
@@ -215,8 +224,15 @@ async fn nip11_or_ws_handler(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
// Only advertise NIP-11 `self` when a stable relay key is configured.
|
||||
let relay_pubkey = if state.config.relay_private_key.is_some() {
|
||||
Some(state.relay_keypair.public_key().to_hex())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if accept.contains("application/nostr+json") {
|
||||
let info = RelayInfo::from_config(&state.config);
|
||||
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
|
||||
return Json(info).into_response();
|
||||
}
|
||||
|
||||
@@ -226,7 +242,7 @@ async fn nip11_or_ws_handler(
|
||||
.into_response(),
|
||||
Err(_) => {
|
||||
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
|
||||
let info = RelayInfo::from_config(&state.config);
|
||||
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
|
||||
Json(info).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Migration 0001: relay_members
|
||||
--
|
||||
-- Introduces the relay-level membership table (NIP-43).
|
||||
-- Replaces the old pubkey_allowlist with a richer model that tracks role
|
||||
-- (owner / admin / member), who added the entry, and timestamps.
|
||||
--
|
||||
-- Idempotent: safe to run more than once.
|
||||
|
||||
-- ── 1. Create relay_members ───────────────────────────────────────────────────
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'relay_members'
|
||||
) THEN
|
||||
CREATE TABLE relay_members (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
added_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_relay_members_role ON relay_members(role);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── 2. Migrate existing allowlist rows ────────────────────────────────────────
|
||||
--
|
||||
-- pubkey_allowlist stores pubkeys as BYTEA and timestamps as added_at.
|
||||
-- Convert BYTEA to lowercase hex text via encode(..., 'hex').
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'pubkey_allowlist'
|
||||
) THEN
|
||||
INSERT INTO relay_members (pubkey, role, added_by, created_at)
|
||||
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at
|
||||
FROM pubkey_allowlist
|
||||
ON CONFLICT (pubkey) DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- NOTE: pubkey_allowlist is intentionally NOT dropped here.
|
||||
-- The old allowlist code still references it. Drop it in a future migration
|
||||
-- once all references have been removed.
|
||||
@@ -329,3 +329,15 @@ CREATE TABLE pubkey_allowlist (
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT
|
||||
);
|
||||
|
||||
-- ── Relay members (NIP-43) ────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_members (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
added_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role);
|
||||
|
||||
Executable
+740
@@ -0,0 +1,740 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# e2e-relay-membership.sh — End-to-end test for NIP-43 relay membership
|
||||
# =============================================================================
|
||||
# Tests relay-level membership enforcement and admin commands.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker services running (postgres, redis, typesense)
|
||||
# - Relay built: cargo build --release --bin sprout-relay
|
||||
# - nak available on PATH (for event signing)
|
||||
#
|
||||
# What it tests:
|
||||
# 1. Non-member REST calls are rejected (401/403)
|
||||
# 2. Owner adds admin via kind:9030
|
||||
# 3. Admin adds member via kind:9030
|
||||
# 4. Owner changes member role via kind:9032
|
||||
# 5. Admin removes member via kind:9031
|
||||
# 6. Members cannot add others (permission denied)
|
||||
# 7. Admins cannot change roles (permission denied)
|
||||
# 8. Owner cannot be removed
|
||||
# 9. GET /api/relay/members returns correct list
|
||||
# 10. GET /api/relay/members/me returns correct role
|
||||
# 11. NIP-11 self field is advertised
|
||||
# 12. kind:13534 membership list published after add
|
||||
# 13. kind:8000 member-added announcement published
|
||||
# 14. kind:28936 leave request (happy path)
|
||||
# 15. kind:8001 member-removed announcement after leave
|
||||
# 16. Leave request without NIP-70 - tag is rejected
|
||||
# 17. Stale admin command rejected (replay protection)
|
||||
# 18. Owner cannot leave (lockout prevention)
|
||||
# 19. Admin cannot add another admin
|
||||
# 20. Admin cannot remove the owner
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}[e2e-membership]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[e2e-membership]${NC} ✓ $*"; }
|
||||
warn() { echo -e "${YELLOW}[e2e-membership]${NC} ⚠ $*"; }
|
||||
|
||||
# Failure counter — tests record failures and continue; fatal() exits immediately.
|
||||
FAILURES=0
|
||||
|
||||
fail() {
|
||||
echo -e "${RED}[e2e-membership]${NC} ✗ $*" >&2
|
||||
FAILURES=$((FAILURES + 1))
|
||||
}
|
||||
|
||||
fatal() {
|
||||
echo -e "${RED}[e2e-membership]${NC} ✗ FATAL: $*" >&2
|
||||
cleanup
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
RELAY_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$RELAY_PID" ]]; then
|
||||
kill "$RELAY_PID" 2>/dev/null || true
|
||||
wait "$RELAY_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Helpers: keypair generation ───────────────────────────────────────────────
|
||||
|
||||
generate_keypair() {
|
||||
openssl rand -hex 32
|
||||
}
|
||||
|
||||
derive_pubkey() {
|
||||
local privkey="$1"
|
||||
python3 -c "
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1; x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2: return None
|
||||
if x1 == x2: lam = (3*x1*x1) * pow(2*y1, P-2, P) % P
|
||||
else: lam = (y2-y1) * pow(x2-x1, P-2, P) % P
|
||||
x3 = (lam*lam - x1 - x2) % P
|
||||
y3 = (lam*(x1-x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None; addend = point
|
||||
while k:
|
||||
if k & 1: result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
pub = scalar_mult(int('$privkey', 16), (Gx, Gy))
|
||||
print(format(pub[0], '064x'))
|
||||
"
|
||||
}
|
||||
|
||||
# ── Helper: sign and send a Nostr event via nak ───────────────────────────────
|
||||
#
|
||||
# Usage: send_event <privkey> <kind> <content> [tag ...]
|
||||
# Each tag is passed as a separate argument in nak --tag format: "key=value"
|
||||
#
|
||||
# Returns the relay response on stdout.
|
||||
|
||||
send_event() {
|
||||
local privkey="$1"
|
||||
local kind="$2"
|
||||
local content="$3"
|
||||
shift 3
|
||||
local tag_args=()
|
||||
for t in "$@"; do
|
||||
tag_args+=(--tag "$t")
|
||||
done
|
||||
|
||||
nak event \
|
||||
--sec "$privkey" \
|
||||
--kind "$kind" \
|
||||
--content "$content" \
|
||||
"${tag_args[@]}" \
|
||||
ws://localhost:3000 2>&1
|
||||
}
|
||||
|
||||
# ── Helper: REST call with X-Pubkey header (dev mode) ────────────────────────
|
||||
#
|
||||
# When SPROUT_REQUIRE_AUTH_TOKEN=false the relay accepts an X-Pubkey header
|
||||
# containing the caller's hex pubkey — no token minting required.
|
||||
# This is the correct pattern for dev-mode E2E tests.
|
||||
#
|
||||
# Returns the HTTP status code on stdout.
|
||||
|
||||
rest_call() {
|
||||
local privkey="$1"
|
||||
local method="$2"
|
||||
local path="$3"
|
||||
local body="${4:-}"
|
||||
|
||||
local pubkey
|
||||
pubkey=$(derive_pubkey "$privkey")
|
||||
|
||||
local curl_args=(-s -o /dev/null -w "%{http_code}" -X "$method"
|
||||
-H "X-Pubkey: $pubkey")
|
||||
if [[ -n "$body" ]]; then
|
||||
curl_args+=(-H "Content-Type: application/json" -d "$body")
|
||||
fi
|
||||
|
||||
curl "${curl_args[@]}" "http://localhost:3000${path}"
|
||||
}
|
||||
|
||||
# ── Helper: REST GET, return body ─────────────────────────────────────────────
|
||||
|
||||
rest_get_body() {
|
||||
local privkey="$1"
|
||||
local path="$2"
|
||||
|
||||
local pubkey
|
||||
pubkey=$(derive_pubkey "$privkey")
|
||||
|
||||
curl -s -X GET \
|
||||
-H "X-Pubkey: $pubkey" \
|
||||
"http://localhost:3000${path}"
|
||||
}
|
||||
|
||||
# ── Helper: check relay OK response ──────────────────────────────────────────
|
||||
#
|
||||
# Returns 0 if the response is ["OK", <id>, true, ...], 1 otherwise.
|
||||
|
||||
is_ok() {
|
||||
echo "$1" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, list) and len(data) >= 3 and data[0] == 'OK' and data[2] is True:
|
||||
sys.exit(0)
|
||||
except: pass
|
||||
sys.exit(1)
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Helper: check relay rejection (OK false) ─────────────────────────────────
|
||||
#
|
||||
# Returns 0 if the response is ["OK", <id>, false, ...], 1 otherwise.
|
||||
|
||||
is_rejected() {
|
||||
echo "$1" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, list) and len(data) >= 3 and data[0] == 'OK' and data[2] is False:
|
||||
sys.exit(0)
|
||||
except: pass
|
||||
sys.exit(1)
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Start relay ───────────────────────────────────────────────────────────────
|
||||
|
||||
log "Starting relay with relay membership enforcement enabled..."
|
||||
|
||||
if [[ -f .env ]]; then
|
||||
set -o allexport
|
||||
source .env
|
||||
set +o allexport
|
||||
fi
|
||||
|
||||
export SPROUT_BIND_ADDR="0.0.0.0:3000"
|
||||
export RELAY_URL="ws://localhost:3000"
|
||||
export RUST_LOG="sprout_relay=warn"
|
||||
export SPROUT_REQUIRE_AUTH_TOKEN=false
|
||||
export SPROUT_REQUIRE_RELAY_MEMBERSHIP=true
|
||||
|
||||
# Generate owner keypair BEFORE relay start — main.rs requires RELAY_OWNER_PUBKEY
|
||||
log "Generating owner keypair..."
|
||||
OWNER_PRIVKEY=$(generate_keypair)
|
||||
OWNER_PUBKEY=$(derive_pubkey "$OWNER_PRIVKEY")
|
||||
export RELAY_OWNER_PUBKEY="$OWNER_PUBKEY"
|
||||
log "Owner pubkey: $OWNER_PUBKEY"
|
||||
|
||||
# Generate a stable relay signing key for NIP-43 self-signed events (kind:13534, etc.)
|
||||
RELAY_SK=$(generate_keypair)
|
||||
export SPROUT_RELAY_PRIVATE_KEY="$RELAY_SK"
|
||||
log "Relay signing key set (NIP-43 self-signed events enabled)"
|
||||
|
||||
# Kill any existing relay
|
||||
pkill -f "sprout-relay" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
./target/release/sprout-relay > /tmp/sprout-relay-membership-e2e.log 2>&1 &
|
||||
RELAY_PID=$!
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
if curl -s http://localhost:3000/ -H "Accept: application/nostr+json" | grep -q "Sprout"; then
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 15 ]]; then
|
||||
fatal "Relay did not start. Check /tmp/sprout-relay-membership-e2e.log"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
success "Relay started (PID $RELAY_PID)"
|
||||
|
||||
# ── Generate identities ───────────────────────────────────────────────────────
|
||||
|
||||
# Generate remaining keypairs (after relay is running)
|
||||
log "Generating remaining keypairs..."
|
||||
ADMIN_PRIVKEY=$(generate_keypair)
|
||||
ADMIN_PUBKEY=$(derive_pubkey "$ADMIN_PRIVKEY")
|
||||
MEMBER_PRIVKEY=$(generate_keypair)
|
||||
MEMBER_PUBKEY=$(derive_pubkey "$MEMBER_PRIVKEY")
|
||||
NONMEMBER_PRIVKEY=$(generate_keypair)
|
||||
NONMEMBER_PUBKEY=$(derive_pubkey "$NONMEMBER_PRIVKEY")
|
||||
ATTACKER_PRIVKEY=$(generate_keypair)
|
||||
ATTACKER_PUBKEY=$(derive_pubkey "$ATTACKER_PRIVKEY")
|
||||
|
||||
log " Owner: ${OWNER_PUBKEY:0:16}..."
|
||||
log " Admin: ${ADMIN_PUBKEY:0:16}..."
|
||||
log " Member: ${MEMBER_PUBKEY:0:16}..."
|
||||
log " Non-member:${NONMEMBER_PUBKEY:0:16}..."
|
||||
log " Attacker: ${ATTACKER_PUBKEY:0:16}..."
|
||||
|
||||
# ── Test 1: Non-member REST calls are rejected ────────────────────────────────
|
||||
|
||||
log "Test 1: Non-member REST calls should be rejected..."
|
||||
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/channels)
|
||||
if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then
|
||||
success "Unauthenticated request rejected ($STATUS)"
|
||||
else
|
||||
fail "Expected 401/403 for unauthenticated request, got $STATUS"
|
||||
fi
|
||||
|
||||
# Non-member with valid auth should also be rejected
|
||||
STATUS=$(rest_call "$NONMEMBER_PRIVKEY" GET /api/channels)
|
||||
if [[ "$STATUS" == "401" || "$STATUS" == "403" ]]; then
|
||||
success "Non-member request rejected ($STATUS)"
|
||||
else
|
||||
fail "Non-member got $STATUS — expected 401 or 403 (membership enforcement bypass!)"
|
||||
fi
|
||||
|
||||
# ── Test 1b: Non-member WebSocket event should be rejected ───────────────────
|
||||
|
||||
log "Test 1b: Non-member WebSocket event should be rejected..."
|
||||
|
||||
# Non-member tries to send a stream message over WebSocket.
|
||||
# The relay enforces NIP-43 during NIP-42 AUTH, so the event will be rejected
|
||||
# before it is processed. Channel ID is irrelevant — auth fires first.
|
||||
RESULT=$(send_event "$NONMEMBER_PRIVKEY" 9 "h=00000000-0000-0000-0000-000000000000" 2>&1 || true)
|
||||
if echo "$RESULT" | grep -qi 'not a relay member\|restricted\|auth\|false'; then
|
||||
success "Non-member WebSocket event rejected"
|
||||
else
|
||||
warn "Could not verify WebSocket non-member rejection: $RESULT"
|
||||
fi
|
||||
|
||||
# ── Test 2: Owner adds admin (kind:9030) ──────────────────────────────────────
|
||||
|
||||
log "Test 2: Owner adds admin via kind:9030..."
|
||||
|
||||
RESULT=$(send_event "$OWNER_PRIVKEY" 9030 "" \
|
||||
"p=$ADMIN_PUBKEY" \
|
||||
"role=admin")
|
||||
if is_ok "$RESULT"; then
|
||||
success "Owner added admin (kind:9030 accepted)"
|
||||
else
|
||||
fail "Owner failed to add admin: $RESULT"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
# Verify admin appears in member list
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$ADMIN_PUBKEY"; then
|
||||
success "Admin pubkey appears in /api/relay/members"
|
||||
else
|
||||
fail "Admin pubkey not found in /api/relay/members: $MEMBERS"
|
||||
fi
|
||||
|
||||
# ── Test 3: Admin adds member (kind:9030) ─────────────────────────────────────
|
||||
|
||||
log "Test 3: Admin adds member via kind:9030..."
|
||||
|
||||
RESULT=$(send_event "$ADMIN_PRIVKEY" 9030 "" \
|
||||
"p=$MEMBER_PUBKEY" \
|
||||
"role=member")
|
||||
if is_ok "$RESULT"; then
|
||||
success "Admin added member (kind:9030 accepted)"
|
||||
else
|
||||
fail "Admin failed to add member: $RESULT"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then
|
||||
success "Member pubkey appears in /api/relay/members"
|
||||
else
|
||||
fail "Member pubkey not found in /api/relay/members: $MEMBERS"
|
||||
fi
|
||||
|
||||
# ── Test 4: Owner changes role (kind:9032) ────────────────────────────────────
|
||||
|
||||
log "Test 4: Owner changes member role to admin via kind:9032..."
|
||||
|
||||
RESULT=$(send_event "$OWNER_PRIVKEY" 9032 "" \
|
||||
"p=$MEMBER_PUBKEY" \
|
||||
"role=admin")
|
||||
if is_ok "$RESULT"; then
|
||||
success "Owner changed member role (kind:9032 accepted)"
|
||||
else
|
||||
fail "Owner failed to change role: $RESULT"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
# Verify role changed
|
||||
MEMBER_INFO=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBER_INFO" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
members = data if isinstance(data, list) else data.get('members', [])
|
||||
for m in members:
|
||||
if m.get('pubkey', '').startswith('${MEMBER_PUBKEY:0:8}') or m.get('pubkey') == '${MEMBER_PUBKEY}':
|
||||
if m.get('role') == 'admin':
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
success "Member role updated to admin in /api/relay/members"
|
||||
else
|
||||
fail "Role change not reflected in /api/relay/members response"
|
||||
fi
|
||||
|
||||
# Reset member back to 'member' role for subsequent tests
|
||||
send_event "$OWNER_PRIVKEY" 9032 "" \
|
||||
"p=$MEMBER_PUBKEY" \
|
||||
"role=member" > /dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
# ── Test 5: Admin removes member (kind:9031) ──────────────────────────────────
|
||||
|
||||
log "Test 5: Admin removes member via kind:9031..."
|
||||
|
||||
RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \
|
||||
"p=$MEMBER_PUBKEY")
|
||||
if is_ok "$RESULT"; then
|
||||
success "Admin removed member (kind:9031 accepted)"
|
||||
else
|
||||
fail "Admin failed to remove member: $RESULT"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then
|
||||
fail "Member pubkey still present in /api/relay/members after removal"
|
||||
else
|
||||
success "Member pubkey absent from /api/relay/members after removal"
|
||||
fi
|
||||
|
||||
# Re-add member for permission tests
|
||||
send_event "$ADMIN_PRIVKEY" 9030 "" \
|
||||
"p=$MEMBER_PUBKEY" \
|
||||
"role=member" > /dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
# ── Test 6: Members cannot add others ────────────────────────────────────────
|
||||
|
||||
log "Test 6: Member attempts to add non-member (should be denied)..."
|
||||
|
||||
RESULT=$(send_event "$MEMBER_PRIVKEY" 9030 "" \
|
||||
"p=$NONMEMBER_PUBKEY" \
|
||||
"role=member")
|
||||
if is_ok "$RESULT"; then
|
||||
fail "Member was able to add another user (should be denied)"
|
||||
elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then
|
||||
success "Member add attempt rejected"
|
||||
else
|
||||
fail "Ambiguous response for member add attempt: $RESULT"
|
||||
fi
|
||||
|
||||
# ── Test 7: Admins cannot change roles ───────────────────────────────────────
|
||||
|
||||
log "Test 7: Admin attempts to change owner role (should be denied)..."
|
||||
|
||||
RESULT=$(send_event "$ADMIN_PRIVKEY" 9032 "" \
|
||||
"p=$OWNER_PUBKEY" \
|
||||
"role=member")
|
||||
if is_ok "$RESULT"; then
|
||||
fail "Admin was able to change owner role (should be denied)"
|
||||
elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then
|
||||
success "Admin role-change on owner rejected"
|
||||
else
|
||||
fail "Ambiguous response for admin role-change attempt: $RESULT"
|
||||
fi
|
||||
|
||||
# ── Test 8: Owner cannot be removed ──────────────────────────────────────────
|
||||
|
||||
log "Test 8: Admin attempts to remove owner (should be denied)..."
|
||||
|
||||
RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \
|
||||
"p=$OWNER_PUBKEY")
|
||||
if is_ok "$RESULT"; then
|
||||
fail "Admin was able to remove the owner (should be denied)"
|
||||
elif is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission'; then
|
||||
success "Owner removal attempt rejected"
|
||||
else
|
||||
fail "Ambiguous response for owner removal attempt: $RESULT"
|
||||
fi
|
||||
|
||||
# Verify owner still present
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then
|
||||
success "Owner still present in /api/relay/members"
|
||||
else
|
||||
fail "Owner missing from /api/relay/members after removal attempt"
|
||||
fi
|
||||
|
||||
# ── Test 9: GET /api/relay/members returns correct list ───────────────────────
|
||||
|
||||
log "Test 9: GET /api/relay/members returns expected members..."
|
||||
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
STATUS=$(rest_call "$OWNER_PRIVKEY" GET /api/relay/members)
|
||||
|
||||
if [[ "$STATUS" == "200" ]]; then
|
||||
success "GET /api/relay/members returned 200"
|
||||
else
|
||||
fail "GET /api/relay/members returned $STATUS (expected 200)"
|
||||
fi
|
||||
|
||||
for pubkey in "$OWNER_PUBKEY" "$ADMIN_PUBKEY" "$MEMBER_PUBKEY"; do
|
||||
if echo "$MEMBERS" | grep -q "$pubkey"; then
|
||||
success " ${pubkey:0:16}... present in member list"
|
||||
else
|
||||
fail " ${pubkey:0:16}... missing from member list"
|
||||
fi
|
||||
done
|
||||
|
||||
if echo "$MEMBERS" | grep -q "$NONMEMBER_PUBKEY"; then
|
||||
fail "Non-member ${NONMEMBER_PUBKEY:0:16}... should not appear in member list"
|
||||
else
|
||||
success "Non-member correctly absent from member list"
|
||||
fi
|
||||
|
||||
# ── Test 10: GET /api/relay/members/me returns correct role ──────────────────
|
||||
|
||||
log "Test 10: GET /api/relay/members/me returns correct role..."
|
||||
|
||||
for pair in "${OWNER_PRIVKEY}:owner" "${ADMIN_PRIVKEY}:admin" "${MEMBER_PRIVKEY}:member"; do
|
||||
privkey="${pair%%:*}"
|
||||
expected_role="${pair##*:}"
|
||||
STATUS=$(rest_call "$privkey" GET /api/relay/members/me)
|
||||
BODY=$(rest_get_body "$privkey" /api/relay/members/me)
|
||||
|
||||
if [[ "$STATUS" == "200" ]]; then
|
||||
if echo "$BODY" | grep -q "\"$expected_role\""; then
|
||||
success " /api/relay/members/me: $expected_role role confirmed (status $STATUS)"
|
||||
else
|
||||
warn " /api/relay/members/me: expected role '$expected_role', got: $BODY"
|
||||
fi
|
||||
else
|
||||
warn " /api/relay/members/me returned $STATUS for $expected_role"
|
||||
fi
|
||||
done
|
||||
|
||||
# Non-member should get 403/404
|
||||
STATUS=$(rest_call "$NONMEMBER_PRIVKEY" GET /api/relay/members/me)
|
||||
if [[ "$STATUS" == "403" || "$STATUS" == "404" ]]; then
|
||||
success "Non-member /api/relay/members/me correctly rejected ($STATUS)"
|
||||
else
|
||||
warn "Non-member /api/relay/members/me returned $STATUS (expected 403/404)"
|
||||
fi
|
||||
|
||||
# ── Test 11: NIP-11 self field is advertised ──────────────────────────────────
|
||||
|
||||
log "Test 11: NIP-11 self field is advertised..."
|
||||
|
||||
RELAY_HTTP="http://localhost:3000"
|
||||
SELF_PK=$(curl -s -H "Accept: application/nostr+json" "$RELAY_HTTP" | jq -r '.self // empty')
|
||||
if [[ -n "$SELF_PK" && "$SELF_PK" != "null" ]]; then
|
||||
success "NIP-11 self field present: ${SELF_PK:0:16}..."
|
||||
else
|
||||
fail "NIP-11 self field missing from relay info document"
|
||||
fi
|
||||
|
||||
# ── Test 12: kind:13534 membership list published after add ───────────────────
|
||||
#
|
||||
# After Test 3 added a member, the relay should have published a signed
|
||||
# kind:13534 membership list event to the DB.
|
||||
|
||||
log "Test 12: kind:13534 membership list published after member add..."
|
||||
|
||||
COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=13534;" 2>/dev/null | tr -d ' \n')
|
||||
if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then
|
||||
success "kind:13534 membership list found in DB ($COUNT row(s))"
|
||||
else
|
||||
fail "kind:13534 not found in DB — relay may not be publishing membership lists"
|
||||
fi
|
||||
|
||||
# ── Test 13: kind:8000 member-added announcement published ────────────────────
|
||||
|
||||
log "Test 13: kind:8000 member-added announcement published..."
|
||||
|
||||
COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=8000;" 2>/dev/null | tr -d ' \n')
|
||||
if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then
|
||||
success "kind:8000 member-added announcement found in DB ($COUNT row(s))"
|
||||
else
|
||||
fail "kind:8000 not found in DB — relay may not be publishing member-added announcements"
|
||||
fi
|
||||
|
||||
# ── Test 14: kind:28936 leave request (happy path) ────────────────────────────
|
||||
#
|
||||
# Member sends a valid leave request with the NIP-70 protected-event `-` tag.
|
||||
# The relay should accept it and remove the member.
|
||||
|
||||
log "Test 14: kind:28936 leave request (happy path)..."
|
||||
|
||||
RESULT=$(send_event "$MEMBER_PRIVKEY" 28936 "" "-=")
|
||||
if is_ok "$RESULT"; then
|
||||
success "Leave request accepted (kind:28936 with NIP-70 - tag)"
|
||||
else
|
||||
fail "Leave request rejected: $RESULT"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
# Verify member was removed
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$MEMBER_PUBKEY"; then
|
||||
fail "Member still present in /api/relay/members after leave request"
|
||||
else
|
||||
success "Member correctly absent from /api/relay/members after leave"
|
||||
fi
|
||||
|
||||
# ── Test 15: kind:8001 member-removed announcement after leave ────────────────
|
||||
|
||||
log "Test 15: kind:8001 member-removed announcement published after leave..."
|
||||
|
||||
COUNT=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM events WHERE kind=8001;" 2>/dev/null | tr -d ' \n')
|
||||
if [[ -n "$COUNT" && "$COUNT" -gt 0 ]]; then
|
||||
success "kind:8001 member-removed announcement found in DB ($COUNT row(s))"
|
||||
else
|
||||
fail "kind:8001 not found in DB — relay may not be publishing member-removed announcements"
|
||||
fi
|
||||
|
||||
# ── Test 16: Leave request without NIP-70 - tag is rejected ──────────────────
|
||||
#
|
||||
# Re-add the member first, then send a leave WITHOUT the required `-` tag.
|
||||
# The relay must reject it (NIP-70 enforcement).
|
||||
|
||||
log "Test 16: Leave request without NIP-70 - tag is rejected..."
|
||||
|
||||
# Re-add member
|
||||
send_event "$ADMIN_PRIVKEY" 9030 "" \
|
||||
"p=$MEMBER_PUBKEY" \
|
||||
"role=member" > /dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
# Send leave without the - tag
|
||||
RESULT=$(send_event "$MEMBER_PRIVKEY" 28936 "")
|
||||
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|nip-70\|protected\|false'; then
|
||||
success "Leave without NIP-70 - tag correctly rejected"
|
||||
else
|
||||
fail "Leave without NIP-70 - tag was accepted (should be rejected): $RESULT"
|
||||
fi
|
||||
|
||||
# ── Test 17: Stale admin command rejected (replay protection) ─────────────────
|
||||
#
|
||||
# An admin command with a created_at more than 5 minutes in the past must be
|
||||
# rejected to prevent replay attacks.
|
||||
|
||||
log "Test 17: Stale admin command rejected (replay protection)..."
|
||||
|
||||
OLD_TS=$(($(date +%s) - 300))
|
||||
# Use nak directly to control the timestamp; pipe output to the relay
|
||||
RESULT=$(nak event \
|
||||
--sec "$ADMIN_PRIVKEY" \
|
||||
--kind 9030 \
|
||||
--created-at "$OLD_TS" \
|
||||
--tag "p=$ATTACKER_PUBKEY" \
|
||||
--tag "role=member" \
|
||||
--content "" \
|
||||
ws://localhost:3000 2>&1 || true)
|
||||
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'stale\|old\|expired\|restricted\|false'; then
|
||||
success "Stale admin command (created_at -5m) rejected"
|
||||
else
|
||||
fail "Stale admin command was accepted (replay protection missing): $RESULT"
|
||||
fi
|
||||
|
||||
# Verify attacker was NOT added
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$ATTACKER_PUBKEY"; then
|
||||
fail "Attacker was added via stale command (replay protection bypass!)"
|
||||
else
|
||||
success "Attacker not present in member list after stale command"
|
||||
fi
|
||||
|
||||
# ── Test 18: Owner cannot leave (lockout prevention) ─────────────────────────
|
||||
#
|
||||
# The relay must refuse a leave request from the owner to prevent lockout.
|
||||
|
||||
log "Test 18: Owner cannot leave (lockout prevention)..."
|
||||
|
||||
RESULT=$(send_event "$OWNER_PRIVKEY" 28936 "" "-=")
|
||||
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'owner\|restricted\|denied\|forbidden\|false'; then
|
||||
success "Owner leave request correctly rejected"
|
||||
else
|
||||
fail "Owner leave request was accepted (lockout prevention missing): $RESULT"
|
||||
fi
|
||||
|
||||
# Verify owner still present
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then
|
||||
success "Owner still present in /api/relay/members after leave attempt"
|
||||
else
|
||||
fail "Owner missing from /api/relay/members after leave attempt"
|
||||
fi
|
||||
|
||||
# ── Test 19: Admin cannot add another admin ───────────────────────────────────
|
||||
#
|
||||
# Admins can only add members. Elevating to admin requires owner privilege.
|
||||
|
||||
log "Test 19: Admin cannot add another admin..."
|
||||
|
||||
RESULT=$(send_event "$ADMIN_PRIVKEY" 9030 "" \
|
||||
"p=$ATTACKER_PUBKEY" \
|
||||
"role=admin")
|
||||
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission\|false'; then
|
||||
success "Admin cannot add another admin (correctly rejected)"
|
||||
else
|
||||
fail "Admin was able to add an admin (privilege escalation!): $RESULT"
|
||||
fi
|
||||
|
||||
# Verify attacker was NOT added as admin
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
members = data if isinstance(data, list) else data.get('members', [])
|
||||
for m in members:
|
||||
if m.get('pubkey') == '${ATTACKER_PUBKEY}' and m.get('role') == 'admin':
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
fail "Attacker has admin role in member list (privilege escalation confirmed!)"
|
||||
else
|
||||
success "Attacker not present as admin in member list"
|
||||
fi
|
||||
|
||||
# ── Test 20: Admin cannot remove the owner ────────────────────────────────────
|
||||
|
||||
log "Test 20: Admin cannot remove the owner..."
|
||||
|
||||
RESULT=$(send_event "$ADMIN_PRIVKEY" 9031 "" \
|
||||
"p=$OWNER_PUBKEY")
|
||||
if is_rejected "$RESULT" || echo "$RESULT" | grep -qi 'restricted\|denied\|forbidden\|unauthorized\|permission\|false'; then
|
||||
success "Admin cannot remove the owner (correctly rejected)"
|
||||
else
|
||||
fail "Admin was able to remove the owner (privilege escalation!): $RESULT"
|
||||
fi
|
||||
|
||||
# Verify owner still present
|
||||
MEMBERS=$(rest_get_body "$OWNER_PRIVKEY" /api/relay/members)
|
||||
if echo "$MEMBERS" | grep -q "$OWNER_PUBKEY"; then
|
||||
success "Owner still present in /api/relay/members after admin removal attempt"
|
||||
else
|
||||
fail "Owner missing from /api/relay/members — admin removed owner!"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "Final member list:"
|
||||
echo "─────────────────────"
|
||||
rest_get_body "$OWNER_PRIVKEY" /api/relay/members | python3 -m json.tool 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
if [[ "$FAILURES" -eq 0 ]]; then
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} ✅ All NIP-43 relay membership E2E tests passed!${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════${NC}"
|
||||
else
|
||||
echo -e "${RED}════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${RED} ❌ ${FAILURES} test(s) failed!${NC}"
|
||||
echo -e "${RED}════════════════════════════════════════════════════════${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user