diff --git a/src/config.rs b/src/config.rs index 60de0d7..8dad866 100644 --- a/src/config.rs +++ b/src/config.rs @@ -150,6 +150,7 @@ pub struct Config { pub retention: RetentionConfig, pub restore: RestoreConfig, pub registration: RegistrationConfig, + pub messaging: MessagingConfig, } #[derive(Clone)] @@ -506,6 +507,44 @@ pub struct PaymentConfig { pub expires_minutes: u32, } +/// Messaging plane (identity + encrypted delivery). Today it holds the optional +/// first-party Nostr relay; the seam leaves room for other messaging backends. +#[derive(Clone)] +pub struct MessagingConfig { + pub relay: RelayConfig, +} + +/// Optional first-party Nostr relay. When `enabled`, the operator runs a relay +/// (the packaged `nostr-rs-relay`, or an external one) that the backend +/// ADVERTISES (NIP-05 / kind-10050) and, for a non-`open` policy, write-restricts +/// via a gRPC event-admission service. It is the user's INBOX relay, never the +/// only relay — clients also use the public interop relays. Self-hosting concern; +/// off by default. Does not derive `Debug` (URL/policy are fine, but kept +/// consistent with the other adapter configs). +#[derive(Clone)] +pub struct RelayConfig { + /// Master switch for the relay integration. + pub enabled: bool, + /// `bundled` (operator runs the packaged nostr-rs-relay) or `external` + /// (point at any relay the operator already runs). The seam handles both. + pub mode: String, + /// Public `wss://` URL clients connect to + we advertise. + pub advertised_url: String, + /// Admission policy the gRPC service enforces: `inbox-outbox` (default), + /// `author-allowlist`, or `open`. + pub write_policy: String, + /// NIP-13 proof-of-work bits required on cross-ecosystem (non-registered- + /// author) inbound events; `0` disables the PoW gate. + pub inbound_pow_bits: u8, + /// Loopback address the gRPC event-admission service binds (nostr-rs-relay + /// calls it per event). Only used when `write_policy != open`. + pub admission_bind: String, + /// Reject events larger than this many bytes (the relay enforces it too). + pub max_event_bytes: usize, + /// Event retention (days) — advisory for operator housekeeping/advertising. + pub retention_days: u32, +} + impl Config { /// Load configuration from the environment and validate it (fail-closed). pub fn from_env() -> Result { @@ -691,6 +730,18 @@ impl Config { expires_minutes: env_parse("PAYMENT_EXPIRES_MINUTES", 60u32)?, }, }, + messaging: MessagingConfig { + relay: RelayConfig { + enabled: env_bool("RELAY_ENABLED", false), + mode: env_or("RELAY_MODE", "bundled").to_lowercase(), + advertised_url: env_or("RELAY_URL", ""), + write_policy: env_or("RELAY_WRITE_POLICY", "inbox-outbox").to_lowercase(), + inbound_pow_bits: env_parse("RELAY_INBOUND_POW_BITS", 0u8)?, + admission_bind: env_or("RELAY_ADMISSION_BIND", "127.0.0.1:8090"), + max_event_bytes: env_parse("RELAY_MAX_EVENT_BYTES", 65536usize)?, + retention_days: env_parse("RELAY_RETENTION_DAYS", 30u32)?, + }, + }, }; cfg.validate()?; @@ -930,6 +981,49 @@ impl Config { } } + // ── Nostr relay (optional messaging plane) ────────────────────────── + if self.messaging.relay.enabled { + let r = &self.messaging.relay; + if !matches!(r.mode.as_str(), "bundled" | "external") { + return Err(cfg_err(format!( + "RELAY_MODE {:?} is not supported; use bundled|external", + r.mode + ))); + } + if r.advertised_url.trim().is_empty() { + return Err(cfg_err( + "RELAY_URL is required when RELAY_ENABLED (the ws(s):// URL clients connect to)", + )); + } + if !(r.advertised_url.starts_with("wss://") || r.advertised_url.starts_with("ws://")) { + return Err(cfg_err("RELAY_URL must be a ws:// or wss:// URL")); + } + if prod && r.advertised_url.starts_with("ws://") { + return Err(cfg_err("RELAY_URL must be wss:// in production")); + } + if !matches!( + r.write_policy.as_str(), + "inbox-outbox" | "author-allowlist" | "open" + ) { + return Err(cfg_err(format!( + "RELAY_WRITE_POLICY {:?} is not supported; use inbox-outbox|author-allowlist|open", + r.write_policy + ))); + } + // Bound PoW difficulty: 0 = off; a value this high is unsolvable in + // practice and would silently reject every inbound event. + if r.inbound_pow_bits > 40 { + return Err(cfg_err("RELAY_INBOUND_POW_BITS must be <= 40 (0 disables)")); + } + // A non-`open` policy is enforced by the gRPC admission service, which + // needs a bind address the relay can reach. + if r.write_policy != "open" && r.admission_bind.trim().is_empty() { + return Err(cfg_err( + "RELAY_ADMISSION_BIND is required for a non-open RELAY_WRITE_POLICY", + )); + } + } + Ok(()) } } @@ -1064,6 +1158,18 @@ mod tests { expires_minutes: 60, }, }, + messaging: MessagingConfig { + relay: RelayConfig { + enabled: false, + mode: "bundled".into(), + advertised_url: String::new(), + write_policy: "inbox-outbox".into(), + inbound_pow_bits: 0, + admission_bind: "127.0.0.1:8090".into(), + max_event_bytes: 65536, + retention_days: 30, + }, + }, } } @@ -1344,6 +1450,75 @@ mod tests { assert!(c.validate().is_err()); } + fn valid_relay() -> RelayConfig { + RelayConfig { + enabled: true, + mode: "bundled".into(), + advertised_url: "wss://relay.example.org".into(), + write_policy: "inbox-outbox".into(), + inbound_pow_bits: 20, + admission_bind: "127.0.0.1:8090".into(), + max_event_bytes: 65536, + retention_days: 30, + } + } + + #[test] + fn relay_off_skips_validation() { + let mut c = valid(); + c.messaging.relay.enabled = false; + c.messaging.relay.write_policy = "nonsense".into(); // ignored when off + assert!(c.validate().is_ok()); + } + + #[test] + fn relay_enabled_full_config_passes() { + let mut c = valid(); + c.messaging.relay = valid_relay(); + assert!(c.validate().is_ok()); + } + + #[test] + fn relay_enabled_requires_url() { + let mut c = valid(); + c.messaging.relay = valid_relay(); + c.messaging.relay.advertised_url = String::new(); + assert!(c.validate().is_err()); + } + + #[test] + fn relay_rejects_non_ws_url() { + let mut c = valid(); + c.messaging.relay = valid_relay(); + c.messaging.relay.advertised_url = "https://relay.example.org".into(); + assert!(c.validate().is_err()); + } + + #[test] + fn relay_rejects_unknown_policy() { + let mut c = valid(); + c.messaging.relay = valid_relay(); + c.messaging.relay.write_policy = "whitelist-only".into(); + assert!(c.validate().is_err()); + } + + #[test] + fn relay_rejects_excessive_pow() { + let mut c = valid(); + c.messaging.relay = valid_relay(); + c.messaging.relay.inbound_pow_bits = 64; + assert!(c.validate().is_err()); + } + + #[test] + fn relay_rejects_ws_in_production() { + let mut c = valid(); + c.environment = "production".into(); + c.messaging.relay = valid_relay(); + c.messaging.relay.advertised_url = "ws://relay.example.org".into(); + assert!(c.validate().is_err()); + } + #[test] fn payment_requires_https_provider_url_in_production() { let mut c = valid(); diff --git a/src/infra/mod.rs b/src/infra/mod.rs index e3bcd7a..90b34b2 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -8,3 +8,4 @@ pub mod grin; pub mod lws; pub mod payment; pub mod prices; +pub mod relay; diff --git a/src/infra/relay/mod.rs b/src/infra/relay/mod.rs new file mode 100644 index 0000000..1946d67 --- /dev/null +++ b/src/infra/relay/mod.rs @@ -0,0 +1,103 @@ +//! Optional first-party Nostr relay (messaging plane). +//! +//! The seam that lets `nostr-rs-relay` be swapped for another relay (strfry, an +//! external relay) or, later, a different messaging protocol — mirroring +//! `infra::payment` (trait + `from_config` → `Option>`, feature-gated, +//! fail-closed) and the client-side ChainProvider seam. +//! +//! The backend does not run the relay process (it never spawns subprocesses — it +//! connects to + advertises external services). Instead it: (1) advertises the +//! relay URL + policy (`/capabilities`, NIP-05 hints), and (2) for a non-`open` +//! write policy, enforces admission via `nostr-rs-relay`'s gRPC event-admission +//! hook, evaluated by the pure engine in [`policy`]. + +mod policy; + +pub use policy::{decide, leading_zero_bits, Admit, EventMeta, WritePolicy, GIFT_WRAP_KIND}; + +use std::sync::Arc; + +use crate::config::Config; +use crate::error::AppError; + +/// NIPs the relay integration speaks (advertised via `/capabilities`). +/// 1 = basic protocol, 44 = encryption, 59 = gift-wrap, 17 = private DMs. +pub const SUPPORTED_NIPS: &[u16] = &[1, 17, 44, 59]; + +/// A messaging-relay backend. Kept thin: the write policy + PoW live in the pure +/// [`policy`] engine, and the admission transport (gRPC nauthz) is the adapter's +/// concern — a future strfry/other adapter can enforce the same policy over a +/// different mechanism. +pub trait RelayProvider: Send + Sync { + /// Adapter kind, e.g. `nostr-rs-relay` or `external`. + fn kind(&self) -> &'static str; + /// The public ws(s):// URL clients connect to + we advertise. + fn advertised_url(&self) -> &str; + /// The configured write policy. + fn write_policy(&self) -> WritePolicy; + /// NIP-13 PoW bits required on cross-ecosystem inbound (0 = off). + fn inbound_pow_bits(&self) -> u8; + /// NIPs advertised as supported. + fn supported_nips(&self) -> &'static [u16] { + SUPPORTED_NIPS + } +} + +/// Config-selected relay provider, or `None` when the relay is disabled. +/// Mirrors [`crate::infra::payment::from_config`]. +pub fn from_config(cfg: &Config) -> Result>, AppError> { + let r = &cfg.messaging.relay; + if !r.enabled { + return Ok(None); + } + // `validate()` already checked the policy string, so this cannot fail in + // practice; fail closed regardless. + let policy = WritePolicy::parse(&r.write_policy).ok_or_else(|| { + AppError::ConfigError(format!("invalid RELAY_WRITE_POLICY: {}", r.write_policy)) + })?; + let provider: Arc = match r.mode.as_str() { + // From the backend's POV, `bundled` and `external` are the same: a relay + // reachable at `advertised_url` that we advertise + admit for. They differ + // only in who runs the process (packaging), a deploy concern. + "bundled" | "external" => Arc::new(NostrRelayProvider { + kind: if r.mode == "bundled" { + "nostr-rs-relay" + } else { + "external" + }, + advertised_url: r.advertised_url.clone(), + policy, + inbound_pow_bits: r.inbound_pow_bits, + }), + other => { + return Err(AppError::ConfigError(format!( + "unsupported RELAY_MODE: {other}" + ))) + } + }; + Ok(Some(provider)) +} + +/// Default adapter: a relay reachable at `advertised_url`. Admission is enforced +/// out-of-band by the gRPC nauthz service using the shared [`policy`] engine. +struct NostrRelayProvider { + kind: &'static str, + advertised_url: String, + policy: WritePolicy, + inbound_pow_bits: u8, +} + +impl RelayProvider for NostrRelayProvider { + fn kind(&self) -> &'static str { + self.kind + } + fn advertised_url(&self) -> &str { + &self.advertised_url + } + fn write_policy(&self) -> WritePolicy { + self.policy + } + fn inbound_pow_bits(&self) -> u8 { + self.inbound_pow_bits + } +} diff --git a/src/infra/relay/policy.rs b/src/infra/relay/policy.rs new file mode 100644 index 0000000..b3b20c1 --- /dev/null +++ b/src/infra/relay/policy.rs @@ -0,0 +1,219 @@ +//! Relay write-admission policy — a PURE decision function. +//! +//! The gRPC admission service (nostr-rs-relay's `nauthz` hook) resolves the two +//! registration booleans against the DB, then calls [`decide`]. Keeping the +//! decision pure makes every branch unit-testable without a relay or a database. +//! +//! Policies (operator-configured, `RELAY_WRITE_POLICY`): +//! * `open` — accept everything (resource caps in the relay itself still apply). +//! * `author-allowlist` — only registered Smirk npubs may publish anything. +//! * `inbox-outbox` (default) — registered npubs publish their own events +//! (outbox); anyone may deliver a NIP-17 gift-wrap (kind 1059) ADDRESSED to a +//! registered user (inbox), optionally behind a NIP-13 proof-of-work gate to +//! blunt cross-ecosystem spam. Everything else is rejected. + +/// NIP-59 gift-wrap event kind (the sealed envelope carrying a NIP-17 DM). +pub const GIFT_WRAP_KIND: u64 = 1059; + +/// Operator write policy. Parsed from the validated `RELAY_WRITE_POLICY` string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WritePolicy { + Open, + AuthorAllowlist, + InboxOutbox, +} + +impl WritePolicy { + /// Parse the config string. Returns `None` for an unknown value (config + /// `validate()` already rejects those at startup, so callers can `expect`). + pub fn parse(s: &str) -> Option { + match s { + "open" => Some(Self::Open), + "author-allowlist" => Some(Self::AuthorAllowlist), + "inbox-outbox" => Some(Self::InboxOutbox), + _ => None, + } + } +} + +/// The admission decision. `Deny` carries a static, non-sensitive reason (the +/// relay may surface it to the publisher). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Admit { + Permit, + Deny(&'static str), +} + +impl Admit { + pub fn is_permit(&self) -> bool { + matches!(self, Admit::Permit) + } +} + +/// The event facts the decision needs. The author + recipient registration +/// lookups are resolved by the caller (against the DB) and passed in as booleans +/// so this stays pure. +pub struct EventMeta<'a> { + /// x-only pubkey hex of the event author (canonical lowercase). + pub author_pubkey: &'a str, + /// Event kind. + pub kind: u64, + /// Event id hex (used for the NIP-13 PoW check). + pub id_hex: &'a str, +} + +/// NIP-13 difficulty: the number of leading zero BITS of the (32-byte) event id. +pub fn leading_zero_bits(id_hex: &str) -> u32 { + let Ok(bytes) = hex::decode(id_hex) else { + return 0; + }; + let mut count = 0u32; + for b in bytes { + if b == 0 { + count += 8; + } else { + count += b.leading_zeros(); // u8::leading_zeros ∈ 0..=7 here + break; + } + } + count +} + +/// Decide whether to admit an event. +/// +/// `author_registered` = the author pubkey is a registered Smirk npub. +/// `recipient_registered` = at least one `p` tag is a registered Smirk npub. +/// (The caller resolves both against the DB before calling.) +pub fn decide( + meta: &EventMeta, + policy: WritePolicy, + inbound_pow_bits: u8, + author_registered: bool, + recipient_registered: bool, +) -> Admit { + match policy { + WritePolicy::Open => Admit::Permit, + WritePolicy::AuthorAllowlist => { + if author_registered { + Admit::Permit + } else { + Admit::Deny("author is not a registered npub") + } + } + WritePolicy::InboxOutbox => { + if author_registered { + // Outbox: a registered user publishing their own events. + Admit::Permit + } else if meta.kind == GIFT_WRAP_KIND && recipient_registered { + // Inbox: an external author delivering a gift-wrapped DM to one of + // our users. Optionally require proof-of-work to blunt spam. + if inbound_pow_bits > 0 + && leading_zero_bits(meta.id_hex) < u32::from(inbound_pow_bits) + { + Admit::Deny("insufficient proof-of-work for cross-ecosystem delivery") + } else { + Admit::Permit + } + } else { + Admit::Deny( + "external authors may only deliver gift-wrapped DMs to registered users", + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta<'a>(author: &'a str, kind: u64, id: &'a str) -> EventMeta<'a> { + EventMeta { + author_pubkey: author, + kind, + id_hex: id, + } + } + + // Placeholder author x-only hex (64 chars). + const A: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + // A 64-char id with zero leading-zero bits. + const FF: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + #[test] + fn parse_roundtrip() { + assert_eq!(WritePolicy::parse("open"), Some(WritePolicy::Open)); + assert_eq!( + WritePolicy::parse("author-allowlist"), + Some(WritePolicy::AuthorAllowlist) + ); + assert_eq!( + WritePolicy::parse("inbox-outbox"), + Some(WritePolicy::InboxOutbox) + ); + assert_eq!(WritePolicy::parse("nope"), None); + } + + #[test] + fn open_permits_everything() { + let m = meta(A, 1, FF); + assert!(decide(&m, WritePolicy::Open, 0, false, false).is_permit()); + } + + #[test] + fn author_allowlist_gates_on_author() { + let m = meta(A, 1, FF); + assert!(decide(&m, WritePolicy::AuthorAllowlist, 0, true, false).is_permit()); + assert!(!decide(&m, WritePolicy::AuthorAllowlist, 0, false, false).is_permit()); + } + + #[test] + fn inbox_outbox_permits_registered_author_outbox() { + let m = meta(A, 1, FF); // any kind, registered author + assert!(decide(&m, WritePolicy::InboxOutbox, 0, true, false).is_permit()); + } + + #[test] + fn inbox_outbox_permits_giftwrap_to_registered_recipient() { + let m = meta(A, GIFT_WRAP_KIND, FF); + assert!(decide(&m, WritePolicy::InboxOutbox, 0, false, true).is_permit()); + } + + #[test] + fn inbox_outbox_rejects_external_non_giftwrap() { + let m = meta(A, 1, FF); // external author, non-gift-wrap + assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, true).is_permit()); + } + + #[test] + fn inbox_outbox_rejects_giftwrap_to_unregistered_recipient() { + let m = meta(A, GIFT_WRAP_KIND, FF); + assert!(!decide(&m, WritePolicy::InboxOutbox, 0, false, false).is_permit()); + } + + #[test] + fn leading_zero_bits_counts_correctly() { + assert_eq!(leading_zero_bits(FF), 0); + assert_eq!(leading_zero_bits(&format!("00{}", "ff".repeat(31))), 8); + assert_eq!(leading_zero_bits(&format!("0f{}", "ff".repeat(31))), 4); + assert_eq!(leading_zero_bits(&format!("0000{}", "ff".repeat(30))), 16); + } + + #[test] + fn inbound_pow_gates_the_inbox_branch() { + // id with 16 leading zero bits. + let id = format!("0000{}", "ff".repeat(30)); + let m = meta(A, GIFT_WRAP_KIND, &id); + // require 8 bits → permit; require 20 → deny. + assert!(decide(&m, WritePolicy::InboxOutbox, 8, false, true).is_permit()); + assert!(!decide(&m, WritePolicy::InboxOutbox, 20, false, true).is_permit()); + } + + #[test] + fn inbound_pow_does_not_gate_registered_outbox() { + // A registered author's low-PoW event is still fine (PoW is inbound-only). + let id = "ff".repeat(32); // 0 leading zero bits + let m = meta(A, 1, &id); + assert!(decide(&m, WritePolicy::InboxOutbox, 20, true, false).is_permit()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1d736e6..dabffe4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,9 @@ pub struct AppState { /// Payment processor backing the optional pay-to-register gate; `Some` only /// when `REGISTRATION_REQUIRE_PAYMENT` is on. pub payment: Option>, + /// Optional first-party Nostr relay (messaging plane); `Some` only when + /// `RELAY_ENABLED` is on. Advertised via `/capabilities` + NIP-05 hints. + pub relay: Option>, /// In-memory website-auth challenges, keyed by nonce. Single-node store; /// a shared/stateless variant is the load-balanced-fleet path. pub web_challenges: Arc>>, diff --git a/src/main.rs b/src/main.rs index b2225fd..7ca5f4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,8 @@ async fn main() -> Result<(), Box> { let chains = ChainClients::from_config(&config)?; // Payment processor for the pay-to-register gate (None unless it's enabled). let payment = smirk_backend_core::infra::payment::from_config(&config)?; + // Optional Nostr relay (messaging plane); None unless RELAY_ENABLED. + let relay = smirk_backend_core::infra::relay::from_config(&config)?; // First-run bootstrap latch (operator §3.2). Only meaningful with the admin // surface enabled. Fail closed on a tampered latch (restore-to-pre-bootstrap). @@ -90,6 +92,7 @@ async fn main() -> Result<(), Box> { sessions, chains, payment, + relay, web_challenges: Arc::default(), prices: prices_cache, admin_sessions, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 856d339..9536912 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -72,6 +72,8 @@ pub async fn try_app() -> Option { let chains = ChainClients::from_config(&config).expect("build chain clients"); let payment = smirk_backend_core::infra::payment::from_config(&config).expect("build payment provider"); + let relay = + smirk_backend_core::infra::relay::from_config(&config).expect("build relay provider"); let prices = Arc::new(tokio::sync::RwLock::new( smirk_backend_core::infra::prices::PriceSnapshot::empty(&config.features.prices_currency), )); @@ -85,6 +87,7 @@ pub async fn try_app() -> Option { sessions, chains, payment, + relay, web_challenges: Arc::default(), prices, admin_sessions,