From de5fd17d2479260e3d16e43420f523cdccd8c874 Mon Sep 17 00:00:00 2001 From: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 13:42:07 -0700 Subject: [PATCH 1/2] feat(relay): add operator community provisioning Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co> --- crates/buzz-db/src/lib.rs | 81 ++++- crates/buzz-relay/src/api/bridge.rs | 18 +- crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/api/operator.rs | 210 ++++++++++++ crates/buzz-relay/src/config.rs | 80 ++++- .../src/handlers/community_provisioning.rs | 304 ++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 2 +- crates/buzz-relay/src/handlers/mod.rs | 2 + crates/buzz-relay/src/router.rs | 9 + 9 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 crates/buzz-relay/src/api/operator.rs create mode 100644 crates/buzz-relay/src/handlers/community_provisioning.rs diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index eb791e441..1c7202db7 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -151,7 +151,7 @@ pub struct DbPoolStats { /// Configuration for the Postgres connection pool. #[derive(Debug, Clone)] pub struct DbConfig { - /// Postgres connection URL (e.g. `postgres://user:pass@host/db`). + /// Postgres connection URL (usually sourced from `DATABASE_URL`). pub database_url: String, /// Maximum number of connections in the pool. pub max_connections: u32, @@ -171,7 +171,7 @@ impl Default for DbConfig { /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. fn default() -> Self { Self { - database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), + database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 max_connections: 20, min_connections: 2, acquire_timeout_secs: 3, @@ -190,6 +190,17 @@ pub struct CommunityRecord { pub host: String, } +/// Community row returned by operator-plane ownership reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// When the community row was created. + pub created_at: DateTime, +} + /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -294,6 +305,43 @@ impl Db { .transpose() } + /// Lists communities where `owner_pubkey` currently holds the `owner` role. + /// + /// This is an operator-plane helper, not a tenant-scoped data-plane read: + /// callers must gate it on deployment-level operator auth before exposing it. + pub async fn list_communities_owned_by( + &self, + owner_pubkey: &str, + ) -> Result> { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let rows = sqlx::query( + r#" + SELECT c.id, c.host, c.created_at + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE rm.pubkey = $1 + AND rm.role = 'owner' + ORDER BY c.created_at ASC, c.host ASC + "#, + ) + .bind(owner_pubkey) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created_at: DateTime = row.try_get("created_at")?; + Ok(OwnedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created_at, + }) + }) + .collect() + } + /// Returns the normalized host mapped to a community id, if the community /// exists. /// @@ -2864,6 +2912,35 @@ mod tests { assert_eq!(found.id, CommunityId::from_uuid(id)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_communities_owned_by_returns_only_owner_rows() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let community_c = CommunityId::from_uuid(make_community(&db.pool).await); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + db.bootstrap_owner(community_a, owner) + .await + .expect("owner A"); + db.bootstrap_owner(community_b, other) + .await + .expect("other owner B"); + db.add_relay_member(community_c, owner, "admin", None) + .await + .expect("admin C"); + + let owned = db + .list_communities_owned_by(owner) + .await + .expect("list owned communities"); + + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].id, community_a); + } + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { let creator: Vec = vec![0u8; 32]; sqlx::query( diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index affea6919..ed01b9580 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -25,7 +25,7 @@ use super::{api_error, internal_error, not_found}; /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). -fn verify_bridge_auth( +pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, @@ -76,7 +76,7 @@ fn verify_bridge_auth( /// `AppState`, not process-local memory. Any Redis/guard error fails closed: /// without the shared `SET NX EX` proof, a stateless worker cannot admit the /// NIP-98 request safely. -async fn check_nip98_replay( +pub(crate) async fn check_nip98_replay( state: &AppState, tenant: &TenantContext, event_id_bytes: [u8; 32], @@ -135,7 +135,11 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. -fn nip98_expected_url(config_relay_url: &str, tenant: &TenantContext, path: &str) -> String { +pub(crate) fn nip98_expected_url( + config_relay_url: &str, + tenant: &TenantContext, + path: &str, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") { "https" } else { @@ -560,6 +564,10 @@ pub async fn submit_event( check_nip98_replay(&state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); + let event: nostr::Event = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; + let kind_u32 = buzz_core::kind::event_kind_u32(&event); + // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); super::relay_members::enforce_relay_membership( @@ -570,16 +578,12 @@ pub async fn submit_event( ) .await?; - let event: nostr::Event = serde_json::from_slice(&body) - .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; - // Mesh signaling kinds (24620 status report, 24621 connect request) are // ephemeral and deliberately absent from ingest_event's per-kind allowlist. // The desktop's Rust coordinator publishes them via this bridge, so route // them to the mesh handlers — the HTTP twin of the WS door's special-casing // in handlers::event. Membership was enforced above; the handlers re-check // it fail-closed. - let kind_u32 = buzz_core::kind::event_kind_u32(&event); if kind_u32 == buzz_core::kind::KIND_MESH_STATUS_REPORT || kind_u32 == buzz_core::kind::KIND_MESH_CONNECT_REQUEST { diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 10a88002b..dbdf94a5c 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod events; pub mod git; pub mod media; pub mod nip05; +pub mod operator; // Re-export imeta helpers used by ingest pipeline. pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs new file mode 100644 index 000000000..9dd867c56 --- /dev/null +++ b/crates/buzz-relay/src/api/operator.rs @@ -0,0 +1,210 @@ +//! Deployment-operator HTTP APIs. +//! +//! These routes are outside the Nostr event data plane. They still use NIP-98 +//! request signing and replay protection, but they do not run through event +//! ingest, relay membership, channel scoping, storage, or fan-out. + +use std::sync::Arc; + +use axum::{ + extract::{Query, RawQuery, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::handlers::community_provisioning::{ + normalize_candidate_host, validate_pubkey_hex, ProvisionCommunityRequest, +}; +use crate::state::AppState; + +use super::{api_error, bridge, internal_error}; + +/// Query parameters for `GET /operator/communities`. +#[derive(Debug, Deserialize)] +pub struct ListCommunitiesQuery { + owner_pubkey: String, +} + +/// Query parameters for `GET /operator/communities/availability`. +#[derive(Debug, Deserialize)] +pub struct CommunityAvailabilityQuery { + host: String, +} + +/// Shared operator auth prelude: bind an ingress host for NIP-98 URL/replay +/// scoping, verify the signed request, then gate on `RELAY_OPERATOR_PUBKEYS`. +async fn authorize_operator_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + raw_query: Option<&str>, + body: Option<&[u8]>, +) -> Result)> { + // Bind to an existing ingress community only for NIP-98 URL/replay scoping. + // This is not a tenant data-plane operation, so do not run relay-membership + // checks and do not route through event ingest. + 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 path_with_query = match raw_query { + Some(q) if !q.is_empty() => format!("{path}?{q}"), + _ => path.to_string(), + }; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth( + headers, method, &url, body, + true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback + )?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + + let pubkey_hex = pubkey.to_hex(); + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &pubkey_hex) + { + return Err(api_error( + StatusCode::FORBIDDEN, + "actor not authorized: not a relay operator", + )); + } + + Ok(pubkey) +} + +/// Provision or converge a community host. +/// +/// `POST /operator/communities`, NIP-98 signed by a pubkey in +/// `RELAY_OPERATOR_PUBKEYS`, body: +/// +/// ```json +/// { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +/// ``` +/// +/// The request is authenticated against the host it arrives on (so NIP-98 `u` +/// still binds to the request authority) but it intentionally does not require +/// relay membership in that host's community. The operator allowlist is the +/// authority for this deployment-root control-plane surface. +pub async fn provision_community( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let pubkey = authorize_operator_request( + &state, + &headers, + "POST", + "/operator/communities", + None, + Some(&body), + ) + .await?; + + let request: ProvisionCommunityRequest = serde_json::from_slice(&body).map_err(|e| { + api_error( + StatusCode::BAD_REQUEST, + &format!("invalid provision-community JSON: {e}"), + ) + })?; + + match crate::handlers::community_provisioning::provision_community(&state, &pubkey, request) + .await + { + Ok(response) => Ok(Json(serde_json::to_value(response).map_err(|e| { + tracing::error!("failed to serialize provision-community response: {e}"); + internal_error("operator provision response serialization failed") + })?)), + Err(msg) if msg.starts_with("actor not authorized") => { + Err(api_error(StatusCode::FORBIDDEN, &msg)) + } + Err(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), + } +} + +/// List communities where a pubkey currently holds the `owner` role. +pub async fn list_owned_communities( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities", + raw_query.as_deref(), + None, + ) + .await?; + + let owner_pubkey = validate_pubkey_hex(&query.owner_pubkey).ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "invalid owner_pubkey: expected 64-char hex pubkey", + ) + })?; + + let rows = state + .db + .list_communities_owned_by(&owner_pubkey) + .await + .map_err(|e| internal_error(&format!("list owned communities: {e}")))?; + + Ok(Json(serde_json::json!({ + "owner_pubkey": owner_pubkey, + "communities": rows.into_iter().map(|row| serde_json::json!({ + "community_id": row.id.to_string(), + "host": row.host, + "created_at": row.created_at, + })).collect::>(), + }))) +} + +/// Check whether a community host is available, returning the relay-canonical +/// normalized authority used by create. +pub async fn community_availability( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + authorize_operator_request( + &state, + &headers, + "GET", + "/operator/communities/availability", + raw_query.as_deref(), + None, + ) + .await?; + + let normalized_host = normalize_candidate_host(&query.host) + .map_err(|msg| api_error(StatusCode::BAD_REQUEST, &msg))?; + let existing = state + .db + .lookup_community_by_host(&normalized_host) + .await + .map_err(|e| internal_error(&format!("check community availability: {e}")))?; + + Ok(Json(serde_json::json!({ + "host": query.host, + "normalized_host": normalized_host, + "available": existing.is_none(), + "community_id": existing.map(|record| record.id.to_string()), + }))) +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index eaa67a052..7e7df2019 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -98,6 +98,19 @@ pub struct Config { /// with the `owner` role on first startup. pub relay_owner_pubkey: Option, + /// Deployment-level relay operator pubkeys allowed to use the + /// `POST /operator/communities` provisioning endpoint. + /// + /// Unlike `relay_owner_pubkey` (a role *within* the deployment community), + /// operators span tenants: they may create new communities and rotate owners + /// via the operator endpoint, but hold no implicit tenant membership row. + /// Empty (the default) disables community provisioning entirely — fail closed. + /// + /// Set via `RELAY_OPERATOR_PUBKEYS` as a comma-separated list of 64-char + /// hex pubkeys. Invalid entries are rejected at startup (config error), not + /// skipped — a typo must not silently disable an operator. + pub relay_operator_pubkeys: Vec, + /// Allow NIP-OA owner attestation for relay membership. /// /// When `true` and `require_relay_membership` is also `true`, agents @@ -184,7 +197,7 @@ impl Config { let bind_addr = parse_bind_addr(&bind_addr_raw)?; let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); @@ -260,6 +273,34 @@ impl Config { } }); + // Note: intentionally not prefixed with BUZZ_ — same relay-identity + // config family as RELAY_OWNER_PUBKEY. Comma-separated 64-char hex + // pubkeys. Unlike RELAY_OWNER_PUBKEY (warn-and-ignore), an invalid + // entry here is a hard config error: silently dropping an operator + // pubkey would silently disable provisioning for that operator. + let relay_operator_pubkeys = match std::env::var("RELAY_OPERATOR_PUBKEYS") { + Ok(raw) => { + let mut pubkeys = Vec::new(); + for entry in raw.split(',') { + let entry = entry.trim().to_lowercase(); + if entry.is_empty() { + continue; + } + let valid = entry.len() == 64 && entry.chars().all(|c| c.is_ascii_hexdigit()); + if !valid { + return Err(ConfigError::InvalidValue(format!( + "RELAY_OPERATOR_PUBKEYS entry is not a valid 64-char hex pubkey: {entry:?}" + ))); + } + if !pubkeys.contains(&entry) { + pubkeys.push(entry); + } + } + pubkeys + } + Err(_) => Vec::new(), + }; + let auth = buzz_auth::AuthConfig::default(); if !require_auth_token { @@ -428,6 +469,7 @@ impl Config { require_relay_membership, huddle_audio_available, relay_owner_pubkey, + relay_operator_pubkeys, allow_nip_oa_auth, media, media_max_concurrent_uploads, @@ -477,6 +519,10 @@ mod tests { config.relay_owner_pubkey.is_none(), "relay_owner_pubkey should default to None" ); + assert!( + config.relay_operator_pubkeys.is_empty(), + "relay_operator_pubkeys should default empty (provisioning disabled)" + ); assert!( !config.allow_nip_oa_auth, "allow_nip_oa_auth should default to false" @@ -487,6 +533,38 @@ mod tests { ); } + #[test] + fn relay_operator_pubkeys_parse_dedupe_and_normalize() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var( + "RELAY_OPERATOR_PUBKEYS", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let config = Config::from_env().expect("config"); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + + assert_eq!( + config.relay_operator_pubkeys, + vec![ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ] + ); + } + + #[test] + fn relay_operator_pubkeys_invalid_entry_is_error() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("RELAY_OPERATOR_PUBKEYS", "not-a-pubkey"); + let result = Config::from_env(); + std::env::remove_var("RELAY_OPERATOR_PUBKEYS"); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("RELAY_OPERATOR_PUBKEYS") + )); + } + #[test] fn huddle_audio_available_can_be_disabled_for_horizontal_scaling() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs new file mode 100644 index 000000000..882c47d39 --- /dev/null +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -0,0 +1,304 @@ +//! Relay-operator community provisioning HTTP handler support. +//! +//! ## Authorization: operator, not owner +//! +//! Every other admin surface in this relay is community-scoped — the sender's +//! role is looked up in `relay_members (community_id, pubkey)` for the +//! host-resolved tenant. Community *creation* cannot work that way: its effect +//! is the creation of tenancy itself, so the authorizing identity must sit +//! above tenants. The gate here is the deployment-level +//! `RELAY_OPERATOR_PUBKEYS` allowlist (see `Config::relay_operator_pubkeys`). +//! An empty allowlist (the default) disables provisioning entirely. +//! +//! The public surface is `POST /operator/communities`, authenticated by NIP-98 +//! and gated by the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. The +//! endpoint is intentionally outside the Nostr event ingest data plane: no +//! relay-membership bypass, no special event kind, no storage or fan-out. +//! +//! ## Request shape +//! +//! ```json +//! { "host": "acme.communities.buzz.xyz", "initial_owner_pubkey": "" } +//! ``` +//! +//! `initial_owner_pubkey` is optional. When present for an existing community, +//! it rotates that community owner through the same bootstrap path used by +//! `RELAY_OWNER_PUBKEY`; relay operators are deployment-root authorities. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tracing::info; + +use buzz_core::tenant::normalize_host; + +use crate::state::AppState; + +/// Maximum accepted host length. Hostnames cap at 253 octets; leave +/// headroom for a `:port` suffix. +const MAX_HOST_LEN: usize = 260; + +/// JSON body for `POST /operator/communities`. +#[derive(Debug, Deserialize)] +pub struct ProvisionCommunityRequest { + /// Normalized authority for the community to ensure/create. + pub host: String, + /// Optional initial owner pubkey. When set on an existing community this + /// rotates the owner through the same bootstrap path used at startup. + #[serde(default)] + pub initial_owner_pubkey: Option, +} + +/// JSON response from `POST /operator/communities`. +#[derive(Debug, Serialize)] +pub struct ProvisionCommunityResponse { + /// UUID of the ensured/created community. + pub community_id: String, + /// Canonical host stored on the community row. + pub host: String, + /// `created` when the host row was inserted, `existed` when it was already + /// present and the request converged idempotently. + pub status: &'static str, + /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_pubkey: Option, +} + +pub(crate) fn validate_pubkey_hex(value: &str) -> Option { + let normalized = value.to_ascii_lowercase(); + (normalized.len() == 64 && normalized.chars().all(|c| c.is_ascii_hexdigit())) + .then_some(normalized) +} + +/// Validate a normalized host value for a community. +/// +/// The host must already be in normalized shape (`normalize_host` is a +/// no-op on it): lowercase, no default port, no trailing dot. Requiring the +/// caller to send the normalized form keeps the stored `communities.host` +/// value byte-identical to what request-time host resolution will look up. +fn validate_host(host: &str) -> Result<(), String> { + if host.is_empty() { + return Err("host is empty".to_string()); + } + if host.len() > MAX_HOST_LEN { + return Err(format!( + "host too long: {} bytes (max {MAX_HOST_LEN})", + host.len() + )); + } + if host.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("host contains invalid characters".to_string()); + } + if host.contains('/') || host.contains('?') || host.contains('#') || host.contains('@') { + return Err( + "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), + ); + } + if normalize_host(host) != host { + return Err(format!( + "host is not normalized: expected {:?}", + normalize_host(host) + )); + } + Ok(()) +} + +/// Normalize and validate a host supplied to read-only operator endpoints. +/// +/// Unlike create, availability checks may accept non-canonical but normalizable +/// authority values (uppercase host, trailing dot, default port) so kgoose can +/// ask the relay for the canonical spelling before creating. Schemes, paths, +/// userinfo, whitespace/control characters, and oversized values are still +/// rejected. +pub(crate) fn normalize_candidate_host(host: &str) -> Result { + if host.is_empty() { + return Err("host is empty".to_string()); + } + if host.len() > MAX_HOST_LEN { + return Err(format!( + "host too long: {} bytes (max {MAX_HOST_LEN})", + host.len() + )); + } + if host.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("host contains invalid characters".to_string()); + } + if host.contains('/') || host.contains('?') || host.contains('#') || host.contains('@') { + return Err( + "host must be a bare authority (no scheme, path, query, or userinfo)".to_string(), + ); + } + + let normalized = normalize_host(host); + validate_host(&normalized)?; + Ok(normalized) +} + +/// Validate and execute a relay-operator community provisioning request. +/// +/// The caller is an HTTP operator endpoint, not the Nostr event ingest path. +/// That keeps the tenant data-plane fences unchanged: no relay-membership +/// bypass, no special event kind, no command routed ahead of moderation/write +/// blocks. The endpoint authenticates its NIP-98 signer first, then passes the +/// signer here for the deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist. +/// +/// Idempotency and owner semantics: the request is idempotent on the host row +/// (re-sending it never duplicates a community). When `initial_owner_pubkey` is +/// present, the owner is (re)bootstrapped via [`buzz_db::Db::bootstrap_owner`] +/// even if the community already existed — any previous owner is demoted to +/// admin, exactly like rotating `RELAY_OWNER_PUBKEY` for the deployment +/// community. This makes a retry after a partial failure (row created, owner +/// bootstrap crashed) converge, at the cost that an operator-signed request can +/// rotate an existing community's owner. The operator allowlist is therefore +/// documented as deployment-root authority, not create-only authority. +pub async fn provision_community( + state: &Arc, + operator_pubkey: &nostr::PublicKey, + request: ProvisionCommunityRequest, +) -> Result { + let operator_hex = operator_pubkey.to_hex(); + + // Operator gate. Deliberately NOT a relay_members lookup: provisioning + // authority spans tenants and lives in deployment config only. Empty + // allowlist → everyone is rejected (fail closed). + if !state + .config + .relay_operator_pubkeys + .iter() + .any(|pk| pk == &operator_hex) + { + return Err("actor not authorized: not a relay operator".to_string()); + } + + validate_host(&request.host)?; + + let initial_owner = request + .initial_owner_pubkey + .as_deref() + .map(|value| { + validate_pubkey_hex(value).ok_or_else(|| { + "invalid initial_owner_pubkey: expected 64-char hex pubkey".to_string() + }) + }) + .transpose()?; + + let existed = state + .db + .lookup_community_by_host(&request.host) + .await + .map_err(|e| format!("database error: {e}"))? + .is_some(); + + // Same idempotent upsert as the startup seed — creating a community is an + // INSERT, never DDL (docs/multi-tenant-relay.md §System Model). + let record = state + .db + .ensure_configured_community(&request.host) + .await + .map_err(|e| format!("failed to create community: {e}"))?; + + if let Some(owner_hex) = &initial_owner { + state + .db + .bootstrap_owner(record.id, owner_hex) + .await + .map_err(|e| format!("community provisioned but owner bootstrap failed: {e}"))?; + } + + info!( + operator = %operator_hex, + community = %record.id, + host = %record.host, + owner = initial_owner.as_deref().unwrap_or(""), + existed, + "community provisioned via operator endpoint" + ); + + Ok(ProvisionCommunityResponse { + community_id: record.id.to_string(), + host: record.host, + status: if existed { "existed" } else { "created" }, + owner_pubkey: initial_owner, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_valid_bare_domain() { + assert!(validate_host("acme.communities.buzz.xyz").is_ok()); + } + + #[test] + fn host_valid_with_port() { + assert!(validate_host("localhost:3000").is_ok()); + } + + #[test] + fn host_rejects_empty() { + assert!(validate_host("").is_err()); + } + + #[test] + fn host_rejects_uppercase() { + assert!(validate_host("Acme.example").is_err()); + } + + #[test] + fn host_rejects_default_port() { + assert!(validate_host("acme.example:443").is_err()); + assert!(validate_host("acme.example:80").is_err()); + } + + #[test] + fn host_rejects_trailing_dot() { + assert!(validate_host("acme.example.").is_err()); + } + + #[test] + fn host_rejects_scheme_path_userinfo() { + assert!(validate_host("wss://acme.example").is_err()); + assert!(validate_host("acme.example/path").is_err()); + assert!(validate_host("user@acme.example").is_err()); + assert!(validate_host("acme.example?x=1").is_err()); + assert!(validate_host("acme.example#frag").is_err()); + } + + #[test] + fn host_rejects_whitespace_and_control() { + assert!(validate_host("acme .example").is_err()); + assert!(validate_host("acme\n.example").is_err()); + } + + #[test] + fn host_rejects_oversized() { + let long = format!("{}.example", "a".repeat(260)); + assert!(validate_host(&long).is_err()); + } + + #[test] + fn host_accepts_ipv6_bracket_literal() { + assert!(validate_host("[::1]:3000").is_ok()); + } + + #[test] + fn candidate_host_normalizes_safe_variants() { + assert_eq!( + normalize_candidate_host("Acme.Example:443").unwrap(), + "acme.example" + ); + assert_eq!( + normalize_candidate_host("acme.example.").unwrap(), + "acme.example" + ); + } + + #[test] + fn candidate_host_rejects_non_authorities() { + assert!(normalize_candidate_host("https://acme.example").is_err()); + assert!(normalize_candidate_host("acme.example/path").is_err()); + assert!(normalize_candidate_host("acme .example").is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4b348a520..2a47c7bb5 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -200,7 +200,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + .route( + "/operator/communities", + get(api::operator::list_owned_communities).post(api::operator::provision_community), + ) + .route( + "/operator/communities/availability", + get(api::operator::community_availability), + ) // 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)) @@ -96,6 +104,7 @@ pub fn build_router(state: Arc) -> Router { // Reserved API prefixes must 404 normally, not serve index.html. let reserved = path.starts_with("/api/") || path.starts_with("/media/") + || path.starts_with("/operator/") || path.starts_with("/git/") || path.starts_with("/internal/") || path.starts_with("/.well-known/") From f2d84bd906c115c8dd3b170dd1fc4fb50d4c44a8 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Wed, 8 Jul 2026 18:29:38 -0400 Subject: [PATCH 2/2] docs(spec): model the operator provisioning plane (S9) in Tamarin + prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #1657 (mini/community-provisioning). The operator plane (POST /operator/communities) is the first runtime surface that can mint tenancy, so it gets the same mechanized treatment as the rest of the multi-tenant model: Tamarin (docs/spec/MultiTenantAuth.spthy, new S9 section): - Rules: Register_Operator / Compromise_Operator_Key, operator-signed provision + rotate requests (NIP-98 preimage includes the payload hash h() — payload binding mandatory), relay provision + converge/rotate gated on the !OperatorKey allowlist, and the UniqueHostBinding restriction importing the append-only host map. - Lemmas: provisioning_requires_operator_authorization (allowlist gate, no compromise disjunct — compromise doesn't extend the allowlist), provision_accepts_only_operator_signed_payload (payload binding), rotation_confined_to_host_community (S5/S6-style single witness), plus three exists-trace probes incl. the create-then-converge lifecycle. - Full run: all 38 lemmas verified green (Tamarin 1.12.0 / Maude 3.5.1, ~141 s). Three commented mutations confirmed red: MUTATION_Provision_Unsigned_Body (the optional-payload-tag gap from the #1657 review; 8-step trace), MUTATION_Provision_Any_Client (8 steps), MUTATION_Rotate_Ignore_Host (12 steps). TLA+ (docs/spec/MultiTenantRelay.tla, comment-only): stated assumption P-HOST-APPEND — HostCommunity stays a constant-per-segment function because the host map is append-only (ensure_configured_community's ON CONFLICT upsert never re-points a bound host), so runtime provisioning needs no TLC remodeling. SANY-checked. Prose (docs/multi-tenant-relay.md): S9 section under Authorization soundness, verification-status update (32 -> 38 lemmas, current run figures), P-HOST-APPEND conformance row (incl. the require-payload-tag and signer-checked-allowlist obligations), Mechanized Verification bullet, machine-check-hygiene classification for the new lemmas, and a stale spthy line-reference fix (:403/:413 -> :427/:437). Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- docs/multi-tenant-relay.md | 107 +++++++++-- docs/spec/MultiTenantAuth.spthy | 318 ++++++++++++++++++++++++++++++++ docs/spec/MultiTenantRelay.tla | 16 ++ 3 files changed, 429 insertions(+), 12 deletions(-) diff --git a/docs/multi-tenant-relay.md b/docs/multi-tenant-relay.md index debfddd6c..4a936cb79 100644 --- a/docs/multi-tenant-relay.md +++ b/docs/multi-tenant-relay.md @@ -571,13 +571,47 @@ load-bearing *backstop*. stops a B-stamped token authorizing over an A-host stops a B-host AUTH registering into A. Its in-relay counterpart is I5's open-community branch (`AuthenticateOpenCommunity`, mutation M10). +- **S9 (Operator-plane provisioning confinement).** Runtime community + provisioning (`POST /operator/communities`) is the one admitted surface whose + *effect* spans tenants: it creates a community, binds its host, and + bootstraps or rotates that community's owner. Its authority is the + deployment-level `RELAY_OPERATOR_PUBKEYS` allowlist + (`crates/buzz-relay/src/api/operator.rs`), never a `relay_members` role — + deployment-root authority, documented as such on the endpoint. Three lemmas: + `provisioning_requires_operator_authorization` (every owner + bootstrap/rotation — the plane's only membership effect, emitted by both the + provision and the converge/rotate path — names an operator-registered key; + there is deliberately **no** compromise disjunct, because compromising an + operator's secret does not put a new key on the allowlist, so the claim + holds unconditionally); `provision_accepts_only_operator_signed_payload` + (the accepted `(host, owner)` pair is exactly a pair the named operator + signed — the NIP-98 payload hash over the JSON body is what forces this, so + a captured `Authorization` header can only *replay* the same pair, which is + the documented idempotent convergence, never *re-target* it); and + `rotation_confined_to_host_community` (an owner rotation lands only in the + community bound to the request's host — the request names a host, never a + community id, the same single-witness framing as S5/S6 via + `RotationResolved(owner, used_comm, host, host_comm)`). The payload-binding + lemma pins the PR #1657 review finding that `buzz-auth`'s NIP-98 + verification treats the `payload` tag as *optional* if absent: + `MUTATION_Provision_Unsigned_Body` models exactly that gap (operator signs + without the payload hash; relay accepts body fields from the wire unbound) + and falsifies the lemma, so requiring the payload tag on this endpoint is a + conformance obligation, not a nicety. `Create_Community` remains the + trusted startup-seed path (`ensure_configured_community` at boot); the S9 + theorems quantify over the provisioning action facts, so they constrain the + protocol path without pretending the config path is adversarial. Each Tamarin lemma is paired with an exists-trace sanity lemma (the honest protocol can run), the Tamarin analog of the mutation test. -**Verification status.** S1–S8 are **machine-verified green** on -Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 32 lemmas in -~12s with zero `analyzed` failures. S1/S2: `token_confinement`, +**Verification status.** S1–S9 are **machine-verified green** on +Tamarin 1.12.0 / Maude 3.5.1 — the full selected run verifies all 38 lemmas in +~141s with zero `analyzed` failures. (The step counts and wall-clock figures +below are from earlier, smaller revisions of the model; each addition enlarges +the search space for the pre-existing lemmas — e.g. +`other_community_key_compromise_does_not_authorize` closes at 486 steps in the +current run versus 147 pre-S9 — but every lemma still closes.) S1/S2: `token_confinement`, `cross_community_use_attempts_are_not_authorized`, the two `minted_*_channels_match_stamp` lemmas, `token_stamp_matches_mint`, `cross_community_mint_yields_no_token_for_that_request`, and the @@ -620,6 +654,23 @@ S8 (open-community AUTH confinement): `open_auth_registration_confined_to_host_c (5 steps) proving a legitimate open-community registration is producible, so the confinement lemma is non-vacuous; its in-relay counterpart is the M10 open-AUTH stamp mutation, confirmed red in TLA+ (a 2-state `Inv_AdmissionFence` violation). +S9 (operator-plane provisioning): `provisioning_requires_operator_authorization` +(6 steps), `provision_accepts_only_operator_signed_payload` (7 steps), and +`rotation_confined_to_host_community` (2 steps — the S5/S6 single-witness +framing, so a counterexample is one rule instance), with three exists-trace +probes (`executable_provision`, 12 steps; `executable_rotate`, 10 steps; and +`executable_rotate_after_provision`, 12 steps — the create-then-converge +lifecycle end to end, proving a rotate on an already-bound host lands in the +community the original provision bound). All three S9 mutations are confirmed +red: `MUTATION_Provision_Unsigned_Body` (the payload tag omitted, so the signed +NIP-98 event binds URL/method/freshness but not the JSON body — the PR #1657 +review finding) falsifies `provision_accepts_only_operator_signed_payload` with +an 8-step trace; `MUTATION_Provision_Any_Client` (the `RELAY_OPERATOR_PUBKEYS` +gate replaced by "any valid signature") falsifies +`provisioning_requires_operator_authorization` with an 8-step trace; and +`MUTATION_Rotate_Ignore_Host` (the rotation applied to a community other than +the host's binding — the operator-plane confused deputy) falsifies +`rotation_confined_to_host_community` with a 12-step trace. The S5 confinement lemma was deliberately framed to keep its mutation *cheaply* refutable. An earlier framing joined two action facts @@ -700,6 +751,31 @@ Each axiom is *admitted* per deployment, not assumed universally: token under the deployment's routing/storage shape (and that the seen-set TTL covers the full ±60 s window). A failing test or an unmet gate rejects the deployment. +- **P-HOST-APPEND (operator plane)** — the host→community map is **append-only**: + a host, once bound, is never re-pointed to a different community. Admitted by + `ensure_configured_community`'s upsert + (`crates/buzz-db/src/lib.rs`): `INSERT ... ON CONFLICT (lower(host)) DO UPDATE + SET host = EXCLUDED.host RETURNING id` — a second request for an + already-bound host returns the *same* community id and only refreshes the + host's stored casing; there is no code path (startup seeding or + `POST /operator/communities`) that changes an existing binding's community. + This is the assumption that lets the TLA+ model keep `HostCommunity` a + *constant* function per checked segment (runtime provisioning appends a new + host↦community pair, which is a fresh constant assignment for subsequent + behavior — never a mutation of an existing one), so every TLC result over the + fixed `HostA/HostB/HostBad` harness remains valid across provisioning events + without remodeling `HostCommunity` as a variable. Tamarin imports the same + assumption as the `UniqueHostBinding` restriction and discharges the operator + plane's authorization obligations as S9 (three lemmas + three confirmed-red + mutations). Two further conformance obligations ride on this surface: the + NIP-98 `payload` tag MUST be required (not merely verified-if-present) on + `POST /operator/communities`, since `MUTATION_Provision_Unsigned_Body` shows + an optional payload tag lets a captured `Authorization` header be raced with + a swapped JSON body on the one endpoint that mints tenancy; and + `RELAY_OPERATOR_PUBKEYS` must be checked against the *signer* of the NIP-98 + event, never inferred from the body or connection. A migration lint asserting + no `UPDATE communities SET host` path (mirroring P-RESOLVE's + channel-immutability lint) admits the append-only axiom structurally. ## Prior Art @@ -831,14 +907,16 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. actors, and ids explodes the space; symmetry + bounded observations keep the core isolation surface exhaustively checkable. - **`docs/spec/MultiTenantAuth.spthy`** — the Tamarin authorization model. Run: - `tamarin-prover --prove docs/spec/MultiTenantAuth.spthy`. All 32 lemmas (S1–S8) - verify green (Tamarin 1.12.0 / Maude 3.5.1, ~12 s) — each safety lemma paired with + `tamarin-prover --prove docs/spec/MultiTenantAuth.spthy`. All 38 lemmas (S1–S9) + verify green (Tamarin 1.12.0 / Maude 3.5.1, ~141 s) — each safety lemma paired with a verified exists-trace sanity lemma, and the documented mutations (`MUTATION_Use_Token_Claimed_Community` for S1, the S3 bad-accept and S4 splice-as-append mutations, `MUTATION_Use_Token_ChannelLess_Ignore_Host` for S5's host fence, `MUTATION_Use_Token_Ignore_Host` for S6's channel-bearing - host/channel-agreement fence, and `MUTATION_Admit_Ignore_Community` for S7's - NIP-43 admission confinement) confirmed red. The 32 lemmas include the + host/channel-agreement fence, `MUTATION_Admit_Ignore_Community` for S7's + NIP-43 admission confinement, and the three S9 operator-plane mutations — + `MUTATION_Provision_Unsigned_Body`, `MUTATION_Provision_Any_Client`, + `MUTATION_Rotate_Ignore_Host`) confirmed red. The 38 lemmas include the open-community AUTH pair added with the host-scoped-open-auth surfaces: `open_auth_registration_confined_to_host_community` (2 steps) proves an open-community auto-registration commits to the host-resolved community and never @@ -849,12 +927,13 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. full lemma list, the S5/S6 single-witness framing, and the corrected `other_community_key_compromise_does_not_authorize` vacuity fix. - **Machine-check hygiene.** S1–S8 lemmas close by two distinct shapes. + **Machine-check hygiene.** S1–S9 lemmas close by two distinct shapes. **Rule-shape closure** means the lemma's conclusion follows by unification on a single rule's action multiset: `token_confinement`, `audit_append_advances_same_community_head`, `channelless_use_confined_to_host_community` (the S5 single-witness fact), - `channelbearing_use_agrees_with_host` (the S6 single-witness fact), and + `channelbearing_use_agrees_with_host` (the S6 single-witness fact), + `rotation_confined_to_host_community` (the S9 single-witness fact), and the S2 supporting set (`minted_token_channels_match_stamp`, `minted_request_channels_match_stamp`, `token_stamp_matches_mint`). These are well-formedness guards on the model's @@ -869,9 +948,13 @@ as label-flow non-interference is, to our knowledge, new for a Nostr relay. **Substantive closure** requires cross-rule reasoning over persistent-fact invariance (`cross_community_mint_yields_no_token_for_that_request`, `leaked_token_blast_radius_contained`, - `cross_community_use_attempts_are_not_authorized`), linear-fact lifecycle + `cross_community_use_attempts_are_not_authorized`, + `provisioning_requires_operator_authorization`), linear-fact lifecycle (`cross_community_audit_splice_attempt_is_not_append`), or signed-preimage - unification (`system_event_acceptance_requires_same_community_key_or_compromise`). + unification (`system_event_acceptance_requires_same_community_key_or_compromise`, + `provision_accepts_only_operator_signed_payload` — the S9 payload-binding + claim, which needs both the operator's signature over the payload hash and + the allowlist premise). Tamarin proves both kinds identically; the distinction is for reviewer hygiene, not a weakened theorem claim. This paragraph is prose-only to preserve the `.spthy` byte hash above. @@ -999,7 +1082,7 @@ The model's obligations map to concrete code seams: red — so admit-into-A-then-act-in-B is a *caught* escape, not an invisible one. On the authorization side, NIP-43 member-list events are signed and accepted per-community in Tamarin (`Community_Signs_NIP43_MemberList` / - `Relay_Accepts_NIP43_MemberList`, `MultiTenantAuth.spthy:403`/`:413`), and + `Relay_Accepts_NIP43_MemberList`, `MultiTenantAuth.spthy:427`/`:437`), and `nip43_admission_confined_to_signing_community` proves B's signing key can never admit a pubkey into A (Theorem S7). diff --git a/docs/spec/MultiTenantAuth.spthy b/docs/spec/MultiTenantAuth.spthy index 02a0a94a9..c8c763ba8 100644 --- a/docs/spec/MultiTenantAuth.spthy +++ b/docs/spec/MultiTenantAuth.spthy @@ -472,6 +472,264 @@ rule Relay_Accepts_NIP43_MemberList: // Expected mutation result: `nip43_admission_confined_to_signing_community` // goes red. +// ============================================================================ +// S9: Deployment-operator community provisioning (POST /operator/communities) +// ============================================================================ +// +// PR #1657 adds a deployment-operator HTTP plane that can create a community +// (and its host mapping) at runtime and bootstrap/rotate that community's +// owner. This is intentionally the ONLY admitted surface whose effect spans +// tenants: its authority is the deployment-level RELAY_OPERATOR_PUBKEYS +// allowlist (`!OperatorKey`), never a `relay_members` role. `Create_Community` +// above remains the CONFIG/STARTUP seeding path (ensure_configured_community +// at boot — trusted by deployment, not by protocol); this section models the +// runtime protocol path, so its theorems quantify over the provisioning +// action facts, not over `CommunityCreated`. +// +// Wire shape: one NIP-98-signed POST whose signed preimage includes the +// payload hash over the JSON body (host, owner). The payload binding is +// load-bearing — see MUTATION_Provision_Unsigned_Body below, which models the +// real "NIP-98 payload tag optional" gap (kalvin's review finding #1 on +// PR #1657) and falsifies the S9 lemmas. +// +// Create-vs-rotate is decided by DB state, exactly like the endpoint: the +// SAME signed request provisions when the host is unbound and converges +// (owner bootstrap/rotation) when it is already bound. A replayed create can +// therefore only re-assert the SAME (host, owner) it signed — benign +// convergence, the documented idempotency — because the payload binds both. + +rule Register_Operator: + [ Fr(~sk_op) ] + --[ + OperatorRegistered(pk(~sk_op)) + ]-> + [ + !OperatorKey(pk(~sk_op)), + !OperatorSecret(pk(~sk_op), ~sk_op), + Out(pk(~sk_op)) + ] + +rule Compromise_Operator_Key: + [ !OperatorSecret(op, sk) ] + --[ + OperatorKeyCompromised(op) + ]-> + [ Out(sk) ] + +// The operator signs a provision request for a fresh host. The signed +// preimage commits to the payload hash h() alongside URL, +// method, and freshness — the NIP-98 payload-tag discipline made mandatory. +// `OperatorAuthorizedProvision` is the single authorization witness both +// relay rules (provision + rotate) are checked against: it names the exact +// (host, owner) the operator signed. +rule Operator_Sends_Provision_Request: + [ !OperatorSecret(op, sk), !ClientPublic(owner), Fr(~host), Fr(~url), Fr(~time) ] + --[ + OperatorAuthorizedProvision(op, ~host, owner), + ProvisionRequested(op, ~host, owner) + ]-> + [ + Out( + < 'operator_provision', + op, + ~url, + 'POST', + ~time, + ~host, + owner, + sign(< 'kind27235_operator', op, ~url, 'POST', ~time, h(< ~host, owner >) >, sk) + > + ), + Out(~host) + ] + +// The operator signs the same request shape for an ALREADY-BOUND host: the +// endpoint converges (bootstrap_owner re-runs), which rotates the community +// owner. Deployment-root authority, documented as such on the endpoint. +rule Operator_Sends_Rotate_Request: + [ !OperatorSecret(op, sk), !HostCommunity(host, comm), !ClientPublic(owner), + Fr(~url), Fr(~time) ] + --[ + OperatorAuthorizedProvision(op, host, owner), + RotateRequested(op, host, owner) + ]-> + [ + Out( + < 'operator_provision', + op, + ~url, + 'POST', + ~time, + host, + owner, + sign(< 'kind27235_operator', op, ~url, 'POST', ~time, h(< host, owner >) >, sk) + > + ) + ] + +// Provision path: the host has no existing binding, so the upsert inserts a +// fresh community, binds the host, and bootstraps the owner. The signature is +// verified against the payload hash recomputed over the RECEIVED body — an +// adversary who swaps host/owner in transit fails verification (P-SIG). +// The relay checks the sender against `!OperatorKey` — the model's +// RELAY_OPERATOR_PUBKEYS allowlist. A registered client key is NOT sufficient +// (see MUTATION_Provision_Any_Client). +rule Relay_Provisions_Community: + [ In(< 'operator_provision', op, url, 'POST', time, host, owner, sig >), + !OperatorKey(op), + Fr(~comm), Fr(~sk_comm) ] + --[ + Eq(verify(sig, < 'kind27235_operator', op, url, 'POST', time, h(< host, owner >) >, op), true), + CommunityProvisioned(~comm, host, op), + ProvisionAccepted(op, host, owner), + OwnerBootstrapped(owner, ~comm, host, op), + HostBound(host, ~comm) + ]-> + [ + !Community(~comm), + !CommunitySigningKey(~comm, ~sk_comm), + !HostCommunity(host, ~comm), + AuditHead(~comm, 'genesis'), + !Admitted(owner, ~comm) + ] + +// Converge/rotate path: the host is already bound, so the upsert is a no-op +// on the community row and bootstrap_owner (re)runs for that SAME community. +// The effect is confined to the host's bound community — the request names a +// host, never a community id (see MUTATION_Rotate_Ignore_Host). +rule Relay_Rotates_Owner: + [ In(< 'operator_provision', op, url, 'POST', time, host, owner, sig >), + !OperatorKey(op), + !HostCommunity(host, comm) ] + --[ + Eq(verify(sig, < 'kind27235_operator', op, url, 'POST', time, h(< host, owner >) >, op), true), + OwnerRotated(owner, comm, host, op), + OwnerBootstrapped(owner, comm, host, op), + RotationResolved(owner, comm, host, comm) + ]-> + [ !Admitted(owner, comm) ] + +// Idempotency / append-only host map, imported as a restriction: a host is +// bound to at most one community, ever. Grounded in the implementation: +// `ensure_configured_community` upserts `ON CONFLICT (lower(host)) DO UPDATE +// SET host = EXCLUDED.host RETURNING id` — a second request for the same host +// returns the SAME community id and never re-points the host. This is the +// same append-only assumption the TLA+ model's header states for keeping +// `HostCommunity` a constant-per-segment function; here it excludes traces +// where a replayed provision would mint a second community for a bound host +// (the real relay converges instead — the rotate rule above is that path). +restriction UniqueHostBinding: + "All host c1 c2 #i #j. + HostBound(host, c1) @ i & HostBound(host, c2) @ j ==> #i = #j" + +// MUTATION_Provision_Unsigned_Body (DO NOT ENABLE in the real model): the +// real-world gap this pins is kalvin's finding #1 on PR #1657 — NIP-98 +// verification treats the payload tag as OPTIONAL, so an operator client that +// omits it produces a signature binding only URL/method/freshness, and anyone +// who captures the Authorization header can race it with a swapped JSON body. +// Modeled as the pair: the operator signs WITHOUT the payload hash, and the +// relay accepts the body fields from the wire unbound. +// +// rule MUTATION_Operator_Signs_Without_Payload: +// [ !OperatorSecret(op, sk), !ClientPublic(owner), Fr(~host), Fr(~url), Fr(~time) ] +// --[ +// OperatorAuthorizedProvision(op, ~host, owner), +// ProvisionRequested(op, ~host, owner) +// ]-> +// [ +// Out(< 'operator_provision_nopayload', op, ~url, 'POST', ~time, ~host, owner, +// sign(< 'kind27235_operator_nopayload', op, ~url, 'POST', ~time >, sk) >), +// Out(~host) +// ] +// +// rule MUTATION_Relay_Provisions_Unbound_Body: +// [ In(< 'operator_provision_nopayload', op, url, 'POST', time, host, owner, sig >), +// !OperatorKey(op), +// Fr(~comm), Fr(~sk_comm) ] +// --[ +// Eq(verify(sig, < 'kind27235_operator_nopayload', op, url, 'POST', time >, op), true), +// CommunityProvisioned(~comm, host, op), +// ProvisionAccepted(op, host, owner), +// OwnerBootstrapped(owner, ~comm, host, op), +// HostBound(host, ~comm) +// ]-> +// [ +// !Community(~comm), !CommunitySigningKey(~comm, ~sk_comm), +// !HostCommunity(host, ~comm), AuditHead(~comm, 'genesis'), +// !Admitted(owner, ~comm) +// ] +// +// Expected mutation result: `provision_accepts_only_operator_signed_payload` +// goes red — the adversary intercepts the unsigned-body message, substitutes +// its own (host, owner), and the relay emits ProvisionAccepted for a pair the +// operator never authorized. Confirmed: falsified with an 8-step trace on +// Tamarin 1.12.0 / Maude 3.5.1 (see docs/multi-tenant-relay.md §Mechanized +// Verification). + +// MUTATION_Provision_Any_Client (DO NOT ENABLE in the real model): the relay +// authorizes provisioning from ANY registered client key instead of the +// operator allowlist — the RELAY_OPERATOR_PUBKEYS gate replaced by "any valid +// signature". Modeled by swapping the `!OperatorKey(op)` premise for +// `!ClientPublic(op)` in Relay_Provisions_Community (clients can sign the +// operator preimage with their own key; only the allowlist premise stops the +// relay accepting it). +// +// rule MUTATION_Client_Signs_Provision: +// [ !ClientSecret(op, sk), !ClientPublic(owner), Fr(~host), Fr(~url), Fr(~time) ] +// --[ ClientSignedProvision(op, ~host, owner) ]-> +// [ +// Out(< 'operator_provision', op, ~url, 'POST', ~time, ~host, owner, +// sign(< 'kind27235_operator', op, ~url, 'POST', ~time, h(< ~host, owner >) >, sk) >), +// Out(~host) +// ] +// +// rule MUTATION_Relay_Provisions_For_Any_Client: +// [ In(< 'operator_provision', op, url, 'POST', time, host, owner, sig >), +// !ClientPublic(op), +// Fr(~comm), Fr(~sk_comm) ] +// --[ +// Eq(verify(sig, < 'kind27235_operator', op, url, 'POST', time, h(< host, owner >) >, op), true), +// CommunityProvisioned(~comm, host, op), +// ProvisionAccepted(op, host, owner), +// OwnerBootstrapped(owner, ~comm, host, op), +// HostBound(host, ~comm) +// ]-> +// [ +// !Community(~comm), !CommunitySigningKey(~comm, ~sk_comm), +// !HostCommunity(host, ~comm), AuditHead(~comm, 'genesis'), +// !Admitted(owner, ~comm) +// ] +// +// Expected mutation result: `provisioning_requires_operator_authorization` +// goes red — a mere registered client (never OperatorRegistered, never +// compromised as an operator) mints a community. Confirmed: falsified with an +// 8-step trace on Tamarin 1.12.0 / Maude 3.5.1. + +// MUTATION_Rotate_Ignore_Host (DO NOT ENABLE in the real model): the relay +// applies the owner rotation to a community OTHER than the request host's +// binding — the operator-plane confused deputy (effect escapes the named +// host). The real rule's `!HostCommunity(host, comm)` premise pins the +// effect; this mutation re-binds it to an arbitrary community. +// +// rule MUTATION_Relay_Rotates_Owner_Other_Community: +// [ In(< 'operator_provision', op, url, 'POST', time, host, owner, sig >), +// !OperatorKey(op), +// !HostCommunity(host, host_comm), +// !Community(other_comm) ] +// --[ +// Eq(verify(sig, < 'kind27235_operator', op, url, 'POST', time, h(< host, owner >) >, op), true), +// Neq(host_comm, other_comm), +// OwnerRotated(owner, other_comm, host, op), +// OwnerBootstrapped(owner, other_comm, host, op), +// RotationResolved(owner, other_comm, host, host_comm) +// ]-> +// [ !Admitted(owner, other_comm) ] +// +// Expected mutation result: `rotation_confined_to_host_community` goes red — +// the single RotationResolved witness carries used_comm # host_comm in one +// rule instance (same single-witness framing as S5/S6). Confirmed: falsified +// with a 12-step trace on Tamarin 1.12.0 / Maude 3.5.1. + // ============================================================================ // Independent per-community audit chains // ============================================================================ @@ -684,6 +942,44 @@ lemma cross_community_audit_splice_attempt_is_not_append: CrossCommunityAuditSpliceAttempt(commA, commB, prevA, prevB, forged) @ i ==> not (Ex #j. AuditAppended(commA, prevB, forged) @ j)" +// S9: every owner bootstrap/rotation — the operator plane's only effect, +// emitted by BOTH the provision and the rotate rule — names an +// operator-allowlisted key. Registration (`!OperatorKey`) is the +// RELAY_OPERATOR_PUBKEYS gate; there is intentionally NO compromise +// disjunct: compromising an operator's secret does not put a new key on the +// allowlist, so the claim holds unconditionally. Enabling +// MUTATION_Provision_Any_Client (relay accepts any registered client key) +// falsifies this. +lemma provisioning_requires_operator_authorization: + "All owner comm host op #i. + OwnerBootstrapped(owner, comm, host, op) @ i + ==> (Ex #j. OperatorRegistered(op) @ j & #j < #i)" + +// S9 payload binding: an accepted provision's (host, owner) pair is exactly a +// pair the named operator signed — the payload hash h() in the +// NIP-98 preimage is what forces this. An adversary who captures the +// Authorization material can replay it (benign convergence, same pair) but +// cannot re-target it. The disjunct admits operator-key compromise, after +// which the adversary can sign arbitrary pairs itself. Enabling +// MUTATION_Provision_Unsigned_Body (payload tag omitted — kalvin's finding #1 +// on PR #1657) falsifies this. +lemma provision_accepts_only_operator_signed_payload: + "All op host owner #i. + ProvisionAccepted(op, host, owner) @ i + ==> (Ex #j. OperatorAuthorizedProvision(op, host, owner) @ j & #j < #i) + | (Ex #k. OperatorKeyCompromised(op) @ k & #k < #i)" + +// S9 confinement: an owner rotation lands in exactly the community bound to +// the request's host. Same single-witness framing as the S1-host lemmas: the +// rotate rule emits one RotationResolved(owner, used_comm, host, host_comm) +// fact carrying both the community actually mutated and the host's binding, +// so a counterexample is one rule instance. Enabling +// MUTATION_Rotate_Ignore_Host falsifies this fast. +lemma rotation_confined_to_host_community: + "All owner used_comm host host_comm #i. + RotationResolved(owner, used_comm, host, host_comm) @ i + ==> used_comm = host_comm" + // Reachability / anti-vacuity probes. lemma executable_token_leak: exists-trace @@ -745,4 +1041,26 @@ lemma executable_open_auth_registration: exists-trace "Ex pk comm host #i. OpenCommunityAutoRegistered(pk, comm, host) @ i" +// S9 anti-vacuity probes: the operator plane's provision and rotate paths are +// both reachable, so the S9 safety lemmas are not vacuously true. +lemma executable_provision: + exists-trace + "Ex op host owner #i #j. + OperatorAuthorizedProvision(op, host, owner) @ i + & ProvisionAccepted(op, host, owner) @ j + & #i < #j" + +lemma executable_rotate: + exists-trace + "Ex owner comm host op #i. OwnerRotated(owner, comm, host, op) @ i" + +// A rotate on an already-bound host lands in the community the ORIGINAL +// provision bound — the create-then-converge lifecycle end to end. +lemma executable_rotate_after_provision: + exists-trace + "Ex comm host op op2 owner2 #i #j. + CommunityProvisioned(comm, host, op) @ i + & OwnerRotated(owner2, comm, host, op2) @ j + & #i < #j" + end diff --git a/docs/spec/MultiTenantRelay.tla b/docs/spec/MultiTenantRelay.tla index 007100529..7c98f8e2f 100644 --- a/docs/spec/MultiTenantRelay.tla +++ b/docs/spec/MultiTenantRelay.tla @@ -133,6 +133,22 @@ InitialChannelOwners == [ch \in Channels |-> \* NoCommunity sentinel (an unmapped/unknown host) -> the connection fails closed \* and no channel-less write may derive a community from it. This is the upstream \* of ResolveTenant: ctx.community is *derived* from the host, never free-chosen. +\* +\* Stated assumption (P-HOST-APPEND): HostCommunity is a CONSTANT-per-segment +\* function even though the operator plane (POST /operator/communities) can +\* provision new communities at runtime. The host map is append-only — +\* ensure_configured_community's upsert (ON CONFLICT (lower(host)) DO UPDATE +\* SET host = EXCLUDED.host RETURNING id) returns the SAME community id for an +\* already-bound host and no code path re-points an existing binding — so a +\* provisioning event only extends the function's domain with a fresh +\* host |-> community pair. Every behavior TLC checks over the fixed +\* HostA/HostB/HostBad harness therefore remains valid across provisioning: +\* new hosts are new constant assignments for subsequent behavior, never +\* mutations of the bindings modeled here. The authorization obligations of +\* the operator plane itself (allowlist gate, payload binding, rotation +\* confinement) live in the Tamarin model (MultiTenantAuth.spthy S9, with the +\* append-only assumption imported as the UniqueHostBinding restriction); see +\* docs/multi-tenant-relay.md SS-Conformance (P-HOST-APPEND). HostCommunity == [h \in Hosts |-> CASE h = HostA -> CommA [] h = HostB -> CommB