diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs new file mode 100644 index 0000000000..c82c74f2cc --- /dev/null +++ b/crates/buzz-relay/src/api/invites.rs @@ -0,0 +1,613 @@ +//! Relay invite HTTP API — mint and claim stateless invite codes. +//! +//! Routes (both NIP-98 signed, outside the Nostr event data plane): +//! +//! - `POST /api/invites` — mint an invite code. Caller must hold the `owner` +//! or `admin` role in the tenant community (mirrors the kind:9030 authz). +//! - `POST /api/invites/claim` — claim an invite code. Deliberately **exempt +//! from the relay-membership gate**: the whole point is that the caller is +//! not a member yet. NIP-98 proves control of the joining pubkey; the HMAC +//! on the code proves an admin authorized the join. +//! +//! Token format, key derivation, and security trade-offs live in +//! [`crate::invite_token`]. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::invite_token::{self, DEFAULT_INVITE_TTL_SECS}; +use crate::state::AppState; + +use super::{api_error, bridge, internal_error}; + +/// Fixed-window size for the per-pubkey claim rate limiter. +pub(crate) const CLAIM_RATE_WINDOW: Duration = Duration::from_secs(60); +/// Max claim attempts per pubkey per window. Claims are idempotent and a real +/// user performs exactly one, so this only bounds brute-force probing. +const CLAIM_RATE_LIMIT: u32 = 10; +/// Maximum distinct pubkeys retained by the process-local claim limiter. +/// NIP-98 proves key ownership, not that a key is costly to create, so this +/// bound is required in addition to expiry. +pub(crate) const CLAIM_RATE_CACHE_CAPACITY: u64 = 10_000; + +/// Body for `POST /api/invites`. +#[derive(Debug, Default, Deserialize)] +pub struct MintInviteRequest { + /// Requested lifetime in seconds. Clamped to + /// [`invite_token::MAX_INVITE_TTL_SECS`]; defaults to 72 h. + #[serde(default)] + pub ttl_secs: Option, +} + +/// Body for `POST /api/invites/claim`. +#[derive(Debug, Deserialize)] +pub struct ClaimInviteRequest { + /// The invite code to redeem. + pub code: String, +} + +/// Shared prelude: bind the tenant from the Host header and verify the NIP-98 +/// signature + replay for `path`. +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &url, + Some(body), + true, // invites always require NIP-98; no X-Pubkey dev fallback + true, // POST bodies must be covered by a payload tag + )?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + + Ok((tenant, pubkey)) +} + +/// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin. +/// +/// Returns the code, its expiry, and a shareable landing-page URL on the +/// tenant host. +pub async fn mint_invite( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?; + + // Authz mirrors kind:9030 (add member): owner or admin only. + let sender_hex = pubkey.to_hex(); + let member = state + .db + .get_relay_member(tenant.community(), &sender_hex) + .await + .map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?; + let role = member.map(|m| m.role).unwrap_or_default(); + if role != "owner" && role != "admin" { + return Err(api_error( + StatusCode::FORBIDDEN, + "only relay owners and admins can create invites", + )); + } + + let request: MintInviteRequest = if body.is_empty() { + MintInviteRequest::default() + } else { + serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid invite JSON: {e}"), + ) + })? + }; + + let key = invite_token::derive_invite_key(&state.relay_keypair); + let ttl = request.ttl_secs.unwrap_or(DEFAULT_INVITE_TTL_SECS); + let (code, expires_at) = invite_token::mint_invite(&key, tenant.community(), ttl); + + // Same TLS-posture logic as nip98_expected_url: wss deployments get an + // https landing page URL, ws dev/test deployments get http. + let scheme = if state.config.relay_url.trim_start().starts_with("wss://") { + "https" + } else { + "http" + }; + + tracing::info!( + community = %tenant.community(), + minted_by = %sender_hex, + expires_at, + "relay invite minted" + ); + + Ok(Json(serde_json::json!({ + "code": code, + "expires_at": expires_at, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), code), + }))) +} + +/// Claim an invite code — `POST /api/invites/claim`, NIP-98 signed by the +/// *joining* pubkey. Exempt from the relay-membership gate by design. +pub async fn claim_invite( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + + if claim_rate_limited(&state, tenant.community(), &pubkey) { + return Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + "too many invite claim attempts, slow down", + )); + } + + let request: ClaimInviteRequest = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; + + let key = invite_token::derive_invite_key(&state.relay_keypair); + let payload = invite_token::verify_invite(&key, tenant.community(), &request.code).map_err( + |e| match e { + // Expired is post-MAC: revealing it helps the UX without helping a forger. + invite_token::InviteError::Expired => { + api_error(StatusCode::FORBIDDEN, "invite_expired") + } + // Everything else stays coarse so the endpoint is a poor oracle. + _ => api_error(StatusCode::FORBIDDEN, "invite_invalid"), + }, + )?; + + let claimer_hex = pubkey.to_hex(); + let was_inserted = state + .db + .add_relay_member(tenant.community(), &claimer_hex, &payload.r, Some("invite")) + .await + .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; + + if was_inserted { + tracing::info!( + community = %tenant.community(), + member = %claimer_hex, + "relay member added via invite" + ); + if let Err(e) = publish_nip43_member_added(&tenant, &state, &claimer_hex).await { + tracing::warn!("failed to publish NIP-43 member-added delta after claim: {e}"); + } + if let Err(e) = publish_nip43_membership_list(&tenant, &state).await { + tracing::warn!("failed to publish NIP-43 membership list after claim: {e}"); + } + } + + Ok(Json(serde_json::json!({ + "status": if was_inserted { "joined" } else { "already_member" }, + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": payload.r, + }))) +} + +/// Fixed-window rate limit on claim attempts, keyed by community and claimer +/// pubkey so traffic for one tenant cannot consume another tenant's allowance. +/// +/// Entries expire after one window and the cache has a hard capacity. Both are +/// important because a pre-membership caller can cheaply create fresh Nostr +/// keypairs; retaining one immortal entry per key would make the limiter itself +/// an unbounded-memory denial-of-service vector. +fn claim_rate_limited( + state: &AppState, + community: buzz_core::tenant::CommunityId, + pubkey: &nostr::PublicKey, +) -> bool { + claim_key_rate_limited( + &state.invite_claim_rate_limiter, + (community, pubkey.to_bytes()), + ) +} + +fn claim_key_rate_limited( + cache: &moka::sync::Cache>, + key: crate::state::ScopedPubkeyKey, +) -> bool { + let counter = cache.get_with(key, || Arc::new(std::sync::atomic::AtomicU32::new(0))); + counter.fetch_add(1, Ordering::Relaxed) >= CLAIM_RATE_LIMIT +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use super::{claim_key_rate_limited, CLAIM_RATE_LIMIT}; + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + }; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::Value; + use sha2::{Digest, Sha256}; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::router::build_router; + use crate::state::AppState; + + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark<'a>( + &'a self, + _ctx: &'a buzz_core::TenantContext, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + fn claim_cache( + capacity: u64, + ttl: Duration, + ) -> moka::sync::Cache> { + moka::sync::Cache::builder() + .max_capacity(capacity) + .time_to_live(ttl) + .build() + } + + #[test] + fn claim_limiter_rejects_after_limit() { + let cache = claim_cache(100, Duration::from_secs(60)); + let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [7; 32]); + + for _ in 0..CLAIM_RATE_LIMIT { + assert!(!claim_key_rate_limited(&cache, key)); + } + assert!(claim_key_rate_limited(&cache, key)); + } + + #[test] + fn claim_limiter_expires_entries() { + let cache = claim_cache(100, Duration::from_millis(10)); + let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), [8; 32]); + assert!(!claim_key_rate_limited(&cache, key)); + assert!(cache.get(&key).is_some()); + + std::thread::sleep(Duration::from_millis(25)); + cache.run_pending_tasks(); + + assert!(cache.get(&key).is_none()); + assert!(!claim_key_rate_limited(&cache, key)); + } + + #[test] + fn claim_limiter_isolates_communities_for_same_pubkey() { + let cache = claim_cache(100, Duration::from_secs(60)); + let pubkey = [9; 32]; + let community_a = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xAAAA)); + let community_b = buzz_core::CommunityId::from_uuid(Uuid::from_u128(0xBBBB)); + + for _ in 0..CLAIM_RATE_LIMIT { + assert!(!claim_key_rate_limited(&cache, (community_a, pubkey))); + } + assert!(claim_key_rate_limited(&cache, (community_a, pubkey))); + assert!(!claim_key_rate_limited(&cache, (community_b, pubkey))); + } + + #[test] + fn claim_limiter_bounds_distinct_pubkeys() { + let capacity = 10; + let cache = claim_cache(capacity, Duration::from_secs(60)); + for id in 0..100_u64 { + let mut pubkey = [0; 32]; + pubkey[..8].copy_from_slice(&id.to_le_bytes()); + let key = (buzz_core::CommunityId::from_uuid(Uuid::nil()), pubkey); + assert!(!claim_key_rate_limited(&cache, key)); + } + cache.run_pending_tasks(); + + assert!(cache.entry_count() <= capacity); + } + + fn nip98_auth_header(keys: &Keys, url: &str, body: &[u8]) -> String { + let hash: [u8; 32] = Sha256::digest(body).into(); + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", "POST"]).expect("method tag"), + Tag::parse(["payload", hex::encode(hash).as_str()]).expect("payload tag"), + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let encoded = base64::engine::general_purpose::STANDARD.encode(event_json.as_bytes()); + format!("Nostr {encoded}") + } + + /// Build a closed-relay (`require_relay_membership = true`) test state with + /// a fresh community on `host`; returns `None` when Postgres is unavailable. + async fn invite_test_state(host: &str) -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = TEST_DB_URL.to_string(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.relay_url = format!("wss://{host}"); + // The claim route must work on relays where membership is enforced — + // that is the entire point of an invite. + config.require_relay_membership = true; + + let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + db.ensure_configured_community(host).await.ok()?; + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + async fn post_json( + state: Arc, + host: &str, + path: &str, + keys: &Keys, + body: String, + ) -> axum::response::Response { + let url = format!("https://{host}{path}"); + let auth = nip98_auth_header(keys, &url, body.as_bytes()); + build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header(header::HOST, host) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response") + } + + async fn read_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read response body"); + serde_json::from_slice(&bytes).expect("response JSON") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn owner_mints_and_new_pubkey_claims() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let community_id = community.id; + state + .db + .add_relay_member(community_id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + // Mint. + let response = post_json( + state.clone(), + &host, + "/api/invites", + &owner, + "{}".to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let code = json.get("code").and_then(Value::as_str).expect("code"); + let url = json.get("url").and_then(Value::as_str).expect("url"); + assert!(url.contains("/invite/"), "unexpected url: {url}"); + + // Claim on a closed relay by a pubkey that is not yet a member. + let claim_body = serde_json::json!({ "code": code }).to_string(); + let response = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body.clone(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!(json.get("status").and_then(Value::as_str), Some("joined")); + assert_eq!(json.get("role").and_then(Value::as_str), Some("member")); + + let member = state + .db + .get_relay_member(community_id, &joiner.public_key().to_hex()) + .await + .expect("member lookup") + .expect("joiner is now a member"); + assert_eq!(member.role, "member"); + + // Second claim is idempotent. + let response = post_json(state, &host, "/api/invites/claim", &joiner, claim_body).await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("status").and_then(Value::as_str), + Some("already_member") + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn non_admin_cannot_mint() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let member = Keys::generate(); + let outsider = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let community_id = community.id; + state + .db + .add_relay_member(community_id, &member.public_key().to_hex(), "member", None) + .await + .expect("seed member"); + + for keys in [&member, &outsider] { + let response = + post_json(state.clone(), &host, "/api/invites", keys, "{}".to_string()).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_rejects_invalid_code() { + let host = format!("invites-{}.example", Uuid::new_v4().simple()); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host).await else { + return; + }; + + let body = serde_json::json!({ "code": "garbage.code" }).to_string(); + let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let json = read_json(response).await; + assert_eq!( + json.get("error").and_then(Value::as_str), + Some("invite_invalid") + ); + + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup") + .expect("community exists"); + let is_member = state + .db + .is_relay_member(community.id, &joiner.public_key().to_hex()) + .await + .expect("member check"); + assert!(!is_member, "invalid code must not admit anyone"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn code_minted_for_one_community_fails_on_another() { + let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple()); + let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let joiner = Keys::generate(); + let Some(state) = invite_test_state(&host_a).await else { + return; + }; + state + .db + .ensure_configured_community(&host_b) + .await + .expect("second community"); + let community_a = state + .db + .lookup_community_by_host(&host_a) + .await + .expect("lookup") + .expect("community a"); + state + .db + .add_relay_member(community_a.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + + let response = post_json( + state.clone(), + &host_a, + "/api/invites", + &owner, + "{}".to_string(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + let code = json.get("code").and_then(Value::as_str).expect("code"); + + // Present community A's code on community B's host. + let body = serde_json::json!({ "code": code }).to_string(); + let response = post_json(state, &host_b, "/api/invites/claim", &joiner, body).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index dbdf94a5c6..f180b32557 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod bridge; pub mod events; pub mod git; +pub mod invites; pub mod media; pub mod nip05; pub mod operator; diff --git a/crates/buzz-relay/src/invite_token.rs b/crates/buzz-relay/src/invite_token.rs new file mode 100644 index 0000000000..dda3c495ee --- /dev/null +++ b/crates/buzz-relay/src/invite_token.rs @@ -0,0 +1,329 @@ +//! Stateless relay invite tokens. +//! +//! An invite code is a compact, URL-safe, HMAC-signed blob minted by a relay +//! admin/owner and later presented by a joining user. The relay verifies the +//! signature and expiry, then inserts the presenter into `relay_members` — +//! no server-side invite storage is required. +//! +//! ## Format +//! +//! ```text +//! code = base64url(payload_json) + "." + base64url(hmac_sha256(key, payload_json)) +//! ``` +//! +//! `payload_json` is a canonical JSON object: +//! +//! ```json +//! {"c":"","r":"member","e":1767000000,"n":""} +//! ``` +//! +//! ## Key derivation +//! +//! The HMAC key is derived from the relay's signing secret key: +//! `key = sha256(relay_secret_key_bytes || "buzz-invite-v1")`. Rotating the +//! relay keypair therefore invalidates all outstanding invites, which is the +//! intended blast-radius control for a leaked link. +//! +//! ## Security properties (and non-properties) +//! +//! - Codes are **multi-use until expiry** — there is no server-side "used" +//! bit. Default expiry is deliberately short ([`DEFAULT_INVITE_TTL_SECS`]). +//! - Codes are **community-scoped**: a code minted for community A fails +//! verification when presented to community B, even on the same deployment. +//! - Codes are **role-capped at `member`** at mint time (enforced by the mint +//! route, and re-checked here on verify so a hand-crafted payload with an +//! elevated role is rejected even if it carries a valid MAC from a future +//! buggy caller). +//! - Revocation is coarse: rotate the relay keypair, or remove the member +//! after the fact. Per-code revocation requires the future `relay_invites` +//! table increment. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use buzz_core::tenant::CommunityId; + +type HmacSha256 = Hmac; + +/// Default invite lifetime: 72 hours. +pub const DEFAULT_INVITE_TTL_SECS: u64 = 72 * 60 * 60; + +/// Maximum invite lifetime a mint request may ask for: 30 days. +pub const MAX_INVITE_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// Maximum accepted code length (defense against absurd inputs before any +/// parsing work happens). A real code is ~200 bytes. +const MAX_CODE_LEN: usize = 1024; + +/// Domain-separation label mixed into the HMAC key derivation. +const KEY_DERIVATION_LABEL: &[u8] = b"buzz-invite-v1"; + +/// The signed payload carried inside an invite code. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct InvitePayload { + /// Community the invite admits into (UUID string form). + pub c: String, + /// Role granted on claim. Only `"member"` is valid in v1. + pub r: String, + /// Expiry as unix seconds. + pub e: u64, + /// Random nonce so identically-parameterised invites differ. + pub n: String, +} + +/// Why a code failed verification. Variants are deliberately coarse — the +/// HTTP layer maps all of them to a generic rejection so the endpoint does +/// not become an oracle for forging codes. +#[derive(Debug, PartialEq, Eq)] +pub enum InviteError { + /// Structurally invalid (bad base64, bad JSON, wrong shape, too long). + Malformed, + /// MAC did not verify. + BadSignature, + /// Signature fine, but the expiry has passed. + Expired, + /// Signature fine, but minted for a different community. + WrongCommunity, + /// Signature fine, but the role is not one this relay grants via invite. + InvalidRole, +} + +impl std::fmt::Display for InviteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + InviteError::Malformed => "malformed invite code", + InviteError::BadSignature => "invalid invite signature", + InviteError::Expired => "invite code expired", + InviteError::WrongCommunity => "invite not valid for this relay", + InviteError::InvalidRole => "invite grants an unsupported role", + }; + f.write_str(msg) + } +} + +/// Derive the invite HMAC key from the relay's signing secret. +/// +/// `sha256(secret_key_bytes || label)` — the label domain-separates this use +/// from any other HMAC built on the same keypair. +pub fn derive_invite_key(relay_keys: &nostr::Keys) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(relay_keys.secret_key().as_secret_bytes()); + hasher.update(KEY_DERIVATION_LABEL); + hasher.finalize().into() +} + +fn sign_payload(key: &[u8; 32], payload_bytes: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(payload_bytes); + mac.finalize().into_bytes().to_vec() +} + +/// Mint an invite code for `community`, expiring `ttl_secs` from now. +/// +/// The role is fixed to `"member"` — elevated roles are granted post-join via +/// the existing kind:9032 change-role command, never via a bearer link. +pub fn mint_invite(key: &[u8; 32], community: CommunityId, ttl_secs: u64) -> (String, u64) { + let ttl = ttl_secs.clamp(60, MAX_INVITE_TTL_SECS); + let expires_at = now_unix() + ttl; + + let nonce: [u8; 16] = rand::random(); + let payload = InvitePayload { + c: community.as_uuid().to_string(), + r: "member".to_string(), + e: expires_at, + n: URL_SAFE_NO_PAD.encode(nonce), + }; + let payload_bytes = serde_json::to_vec(&payload).expect("payload serializes"); + let mac = sign_payload(key, &payload_bytes); + + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&payload_bytes), + URL_SAFE_NO_PAD.encode(mac) + ); + (code, expires_at) +} + +/// Verify an invite code presented to `community`. +/// +/// Order matters: signature is checked before any claims inside the payload +/// are trusted (expiry / community / role), and errors after the MAC check +/// still return coarse variants so the endpoint stays a poor oracle. +pub fn verify_invite( + key: &[u8; 32], + community: CommunityId, + code: &str, +) -> Result { + if code.len() > MAX_CODE_LEN { + return Err(InviteError::Malformed); + } + let (payload_b64, mac_b64) = code.split_once('.').ok_or(InviteError::Malformed)?; + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|_| InviteError::Malformed)?; + let mac_bytes = URL_SAFE_NO_PAD + .decode(mac_b64) + .map_err(|_| InviteError::Malformed)?; + + // Constant-time MAC verification before trusting anything in the payload. + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size"); + mac.update(&payload_bytes); + mac.verify_slice(&mac_bytes) + .map_err(|_| InviteError::BadSignature)?; + + let payload: InvitePayload = + serde_json::from_slice(&payload_bytes).map_err(|_| InviteError::Malformed)?; + + if payload.e < now_unix() { + return Err(InviteError::Expired); + } + if payload.c != community.as_uuid().to_string() { + return Err(InviteError::WrongCommunity); + } + if payload.r != "member" { + return Err(InviteError::InvalidRole); + } + Ok(payload) +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn test_key() -> [u8; 32] { + derive_invite_key(&nostr::Keys::generate()) + } + + fn community() -> CommunityId { + CommunityId::from_uuid(Uuid::new_v4()) + } + + #[test] + fn mint_then_verify_roundtrip() { + let key = test_key(); + let c = community(); + let (code, expires_at) = mint_invite(&key, c, 3600); + let payload = verify_invite(&key, c, &code).expect("valid code verifies"); + assert_eq!(payload.c, c.as_uuid().to_string()); + assert_eq!(payload.r, "member"); + assert_eq!(payload.e, expires_at); + } + + #[test] + fn rejects_wrong_community() { + let key = test_key(); + let (code, _) = mint_invite(&key, community(), 3600); + assert_eq!( + verify_invite(&key, community(), &code), + Err(InviteError::WrongCommunity) + ); + } + + #[test] + fn rejects_tampered_payload() { + let key = test_key(); + let c = community(); + let (code, _) = mint_invite(&key, c, 3600); + let (payload_b64, mac_b64) = code.split_once('.').unwrap(); + + // Re-encode a payload with an elevated role but keep the original MAC. + let mut payload: InvitePayload = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap(); + payload.r = "owner".to_string(); + let forged = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()), + mac_b64 + ); + assert_eq!( + verify_invite(&key, c, &forged), + Err(InviteError::BadSignature) + ); + } + + #[test] + fn rejects_wrong_key() { + let c = community(); + let (code, _) = mint_invite(&test_key(), c, 3600); + assert_eq!( + verify_invite(&test_key(), c, &code), + Err(InviteError::BadSignature) + ); + } + + #[test] + fn rejects_expired() { + let key = test_key(); + let c = community(); + // ttl clamps to 60s minimum, so hand-mint an already-expired payload. + let payload = InvitePayload { + c: c.as_uuid().to_string(), + r: "member".to_string(), + e: now_unix() - 10, + n: "n".to_string(), + }; + let bytes = serde_json::to_vec(&payload).unwrap(); + let mac = sign_payload(&key, &bytes); + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&bytes), + URL_SAFE_NO_PAD.encode(mac) + ); + assert_eq!(verify_invite(&key, c, &code), Err(InviteError::Expired)); + } + + #[test] + fn rejects_garbage() { + let key = test_key(); + let c = community(); + assert_eq!(verify_invite(&key, c, ""), Err(InviteError::Malformed)); + assert_eq!( + verify_invite(&key, c, "not-a-code"), + Err(InviteError::Malformed) + ); + // "a" / "b" are not valid base64 payloads. + assert_eq!(verify_invite(&key, c, "a.b"), Err(InviteError::Malformed)); + let huge = "x".repeat(MAX_CODE_LEN + 1); + assert_eq!(verify_invite(&key, c, &huge), Err(InviteError::Malformed)); + } + + #[test] + fn ttl_is_capped() { + let key = test_key(); + let (_, expires_at) = mint_invite(&key, community(), u64::MAX); + assert!(expires_at <= now_unix() + MAX_INVITE_TTL_SECS + 5); + } + + #[test] + fn signed_role_other_than_member_rejected() { + // Even a *correctly signed* payload with an elevated role must fail + // verification (defense against a future buggy mint caller). + let key = test_key(); + let c = community(); + let payload = InvitePayload { + c: c.as_uuid().to_string(), + r: "admin".to_string(), + e: now_unix() + 3600, + n: "n".to_string(), + }; + let bytes = serde_json::to_vec(&payload).unwrap(); + let mac = sign_payload(&key, &bytes); + let code = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(&bytes), + URL_SAFE_NO_PAD.encode(mac) + ); + assert_eq!(verify_invite(&key, c, &code), Err(InviteError::InvalidRole)); + } +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 088177e4de..eab5f63887 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -19,6 +19,8 @@ pub mod connection; pub mod error; /// WebSocket message handlers for NIP-01 client commands. pub mod handlers; +/// Stateless HMAC-signed relay invite tokens (mint/verify). +pub mod invite_token; /// Relay-signed mesh-LLM status publisher. pub mod mesh_status_publisher; /// Prometheus metrics: recorder, upkeep, HTTP middleware. diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 69e0368ccd..5f4bbee7e2 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -68,6 +68,9 @@ pub fn build_router(state: Arc) -> Router { "/operator/communities/availability", get(api::operator::community_availability), ) + // Relay invites: mint (owner/admin) + claim (membership-gate exempt) + .route("/api/invites", post(api::invites::mint_invite)) + .route("/api/invites/claim", post(api::invites::claim_invite)) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) @@ -115,7 +118,10 @@ pub fn build_router(state: Arc) -> Router { || path == "/_status" || path == "/info"; // Files with extensions (e.g. /assets/missing.js) should 404. - let has_ext = path.rsplit('/').next().is_some_and(|seg| seg.contains('.')); + // Exception: /invite/ — invite codes contain a "." + // (payload.mac separator) but are SPA routes, not files. + let has_ext = !path.starts_with("/invite/") + && path.rsplit('/').next().is_some_and(|seg| seg.contains('.')); if reserved || has_ext { Ok(StatusCode::NOT_FOUND.into_response()) } else { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 299b599aec..84c88581e6 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -368,6 +368,12 @@ pub struct AppState { /// Per-uploader sliding-window rate limiter for media upload starts. /// Key: (community_id, uploader pubkey bytes). Value: (count, window_start). pub media_upload_rate_limiter: Arc, + /// Per-claimer fixed-window rate limiter for invite claim attempts + /// (`POST /api/invites/claim`). Entries expire after the claim window and + /// the cache has a hard capacity because pre-membership callers can cheaply + /// generate fresh Nostr keys. + pub invite_claim_rate_limiter: + Arc>>, /// Current in-flight media uploads per (community, uploader pubkey). pub media_uploads_in_flight: Arc>, /// Cache for observer agent-owner authorization (kind 24200). @@ -509,6 +515,12 @@ impl AppState { observer_rate_limiter: Arc::new(DashMap::new()), mesh_connect_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), + invite_claim_rate_limiter: Arc::new( + moka::sync::Cache::builder() + .max_capacity(crate::api::invites::CLAIM_RATE_CACHE_CAPACITY) + .time_to_live(crate::api::invites::CLAIM_RATE_WINDOW) + .build(), + ), media_uploads_in_flight: Arc::new(DashMap::new()), observer_owner_cache: Arc::new( moka::sync::Cache::builder() diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2f2539e3f1..a46112bb82 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -118,6 +118,8 @@ const overrides = new Map([ // New file in this PR; queued to split. // +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix. // +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase). + // +3 lines from readiness PATH/import-identity fixes landed on main after the + // prior ratchet; retain the narrowly scoped override pending the queued split. // +2 unit tests for cli_login_requirements resolve_command integration (DMG PATH fix). // Doctor-CTA: reworked cli_login_requirements to carry AcpAvailabilityStatus, // skip login probe for not-installed/adapter-missing/cli-missing states, and @@ -131,7 +133,7 @@ const overrides = new Map([ // databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all // host/credential match arms + 30+ readiness tests for provider aliases, // missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix. - ["src-tauri/src/managed_agents/readiness.rs", 1546], + ["src-tauri/src/managed_agents/readiness.rs", 1549], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 0833b777e5..f0df5274a6 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -36,6 +36,35 @@ fn parse_message_deep_link(url: &Url) -> Option { })) } +/// Parse the query string of a `buzz://join?…` URL into the JSON payload +/// emitted on `deep-link-join`. Requires a ws(s) `relay` URL and a non-empty +/// `code`; returns `None` otherwise so the frontend never sees a half-formed +/// payload. +fn parse_join_deep_link(url: &Url) -> Option { + let mut relay: Option = None; + let mut code: Option = None; + for (k, v) in url.query_pairs() { + let v = v.into_owned(); + if v.is_empty() { + continue; + } + match k.as_ref() { + "relay" => relay = Some(v), + "code" => code = Some(v), + _ => {} + } + } + let (relay_url, code) = (relay?, code?); + match Url::parse(&relay_url) { + Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} + _ => return None, + } + Some(serde_json::json!({ + "relayUrl": relay_url, + "code": code, + })) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct NostrBindDeepLinkPayload { @@ -142,6 +171,16 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } let _ = app.emit("deep-link-connect", relay_url); } + Some("join") => { + // `buzz://join?relay=&code=` — fired by + // the relay's /invite/ landing page. The frontend claims the + // invite against the relay's HTTP API, then adds the workspace. + let Some(payload) = parse_join_deep_link(&url) else { + eprintln!("buzz-desktop: join deep link missing/invalid relay or code: {url_str}"); + return; + }; + let _ = app.emit("deep-link-join", payload); + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -178,7 +217,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { mod tests { use url::Url; - use super::{parse_message_deep_link, parse_nostr_bind_deep_link}; + use super::{parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link}; fn valid_nostr_bind_url() -> Url { Url::parse( @@ -237,6 +276,38 @@ mod tests { assert!(payload["threadRootId"].is_null()); } + #[test] + fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + } + + #[test] + fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + + #[test] + fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); + } + #[test] fn parse_nostr_bind_deep_link_accepts_valid_url() { let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); diff --git a/desktop/src/features/relay-members/ui/InviteLinkSection.tsx b/desktop/src/features/relay-members/ui/InviteLinkSection.tsx new file mode 100644 index 0000000000..fc0eddca4e --- /dev/null +++ b/desktop/src/features/relay-members/ui/InviteLinkSection.tsx @@ -0,0 +1,165 @@ +import { Check, Copy, Link2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { mintInvite } from "@/shared/api/invites"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const TTL_OPTIONS: { label: string; value: number }[] = [ + { label: "1 day", value: 24 * 60 * 60 }, + { label: "3 days", value: 3 * 24 * 60 * 60 }, + { label: "7 days", value: 7 * 24 * 60 * 60 }, + { label: "30 days", value: 30 * 24 * 60 * 60 }, +]; + +function formatExpiry(expiresAtUnix: number): string { + return new Date(expiresAtUnix * 1000).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +/** + * "Create invite link" section of the relay-access settings card. + * + * Mints a stateless invite code via `POST /api/invites` (owner/admin only — + * the parent card already gates on that) and surfaces the shareable + * `/invite/` landing-page URL. Codes are multi-use until expiry and + * are not individually revocable; the relay key is the revocation lever. + */ +export function InviteLinkSection() { + const [ttlSecs, setTtlSecs] = React.useState(TTL_OPTIONS[1].value); + const [minting, setMinting] = React.useState(false); + const [invite, setInvite] = React.useState<{ + url: string; + expiresAt: number; + } | null>(null); + const [copied, setCopied] = React.useState(false); + + const ttlLabel = + TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; + + async function handleCreate() { + setInvite(null); + setCopied(false); + setMinting(true); + try { + const minted = await mintInvite(ttlSecs); + setInvite({ url: minted.url, expiresAt: minted.expiresAt }); + } catch (error) { + toast.error( + error instanceof Error + ? `Couldn't create invite link: ${error.message}` + : "Couldn't create invite link", + ); + } finally { + setMinting(false); + } + } + + async function handleCopy() { + if (!invite) return; + try { + await navigator.clipboard.writeText(invite.url); + setCopied(true); + toast.success("Invite link copied"); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Couldn't copy to clipboard"); + } + } + + return ( +
+ Invite link +
+
+ + + + + + + Expires after + + setTtlSecs(Number(value))} + value={String(ttlSecs)} + > + {TTL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ {invite ? ( +
+ + {invite.url} + + +
+ ) : null} +
+

+ {invite + ? `Anyone with this link can join as a member until ${formatExpiry(invite.expiresAt)}.` + : "Create a shareable link that lets anyone join this relay as a member until it expires."} +

+
+ ); +} diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx index d4bf1f3296..0af4321ff3 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx +++ b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx @@ -39,6 +39,7 @@ import { Input } from "@/shared/ui/input"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; +import { InviteLinkSection } from "./InviteLinkSection"; type AssignableRelayRole = Exclude; @@ -443,6 +444,8 @@ export function RelayMembersSettingsCard({ ) : null} + + {membersQuery.error instanceof Error ? (

{membersQuery.error.message} diff --git a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx index 421c4c900b..07258fddd3 100644 --- a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx @@ -6,6 +6,11 @@ import { expandTilde, normalizeRelayUrl, } from "@/features/workspaces/workspaceStorage"; +import { + inviteErrorMessage, + isInviteExpiredError, +} from "@/shared/api/inviteHelpers"; +import { claimInvite } from "@/shared/api/invites"; import { validateReposDir } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { @@ -31,6 +36,8 @@ export function AddWorkspaceDialog({ const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); + const [inviteCode, setInviteCode] = React.useState(""); + const [inviteError, setInviteError] = React.useState(null); const [reposDir, setReposDir] = React.useState(""); const [reposDirError, setReposDirError] = React.useState(null); @@ -39,6 +46,8 @@ export function AddWorkspaceDialog({ setName(""); setRelayUrl(""); setToken(""); + setInviteCode(""); + setInviteError(null); setReposDir(""); setReposDirError(null); }, [onOpenChange]); @@ -62,10 +71,27 @@ export function AddWorkspaceDialog({ return; } + // If the relay handed out an invite code, claim it before saving the + // workspace — a closed relay would otherwise reject the connection. + const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim()); + if (inviteCode.trim()) { + try { + await claimInvite(normalizedRelayUrl, inviteCode.trim()); + } catch (error) { + const message = inviteErrorMessage(error); + setInviteError( + isInviteExpiredError(error) + ? "This invite code has expired — ask for a new one." + : `Invite rejected: ${message}`, + ); + return; + } + } + const workspace: Workspace = { id: crypto.randomUUID(), name: name.trim() || deriveWorkspaceName(relayUrl.trim()), - relayUrl: normalizeRelayUrl(relayUrl.trim()), + relayUrl: normalizedRelayUrl, token: token.trim() || undefined, reposDir: expandedReposDir, addedAt: new Date().toISOString(), @@ -74,7 +100,7 @@ export function AddWorkspaceDialog({ onSubmit(workspace); handleClose(); }, - [name, relayUrl, token, reposDir, onSubmit, handleClose], + [name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose], ); return ( @@ -143,6 +169,30 @@ export function AddWorkspaceDialog({ value={token} /> +

+ + { + setInviteCode(e.target.value); + setInviteError(null); + }} + placeholder="Paste an invite code for a members-only relay" + type="text" + value={inviteCode} + /> + {inviteError ? ( +

{inviteError}

+ ) : null} +