mesh-llm plan v6.1: Step 3 — embedded iroh-relay with NIP-98 admission

- New module crates/sprout-relay/src/iroh_relay.rs (~290 lines incl. tests).
- pub fn spawn(state, bind_addr) constructs an iroh_relay::server::Server
  with AccessConfig::Restricted set to a closure that:
    1. Pulls the Bearer token from ClientRequest::auth_token().
    2. base64-decodes (accepts STANDARD + URL_SAFE, padded or not).
    3. Calls sprout_auth::verify_nip98_event against canonical URL
       (= sprout_auth::nip98_canonical_url(public_url, '/relay')).
    4. Runs check_relay_membership against the NIP-98 pubkey.
       Anything other than Member/ViaOwner/OpenRelay -> Deny.
  Per Max's review notes: fail-closed on missing/invalid token, run
  membership only after NIP-98 verifies the pubkey, no caching.
- Returns Ok(None) gracefully when SPROUT_IROH_RELAY_PUBLIC_URL is unset
  (the canonical URL can't be built without it).
- patched-iroh-relay feature flag reserved for upstream PR C's per-client
  max-lifetime hook (kept behind cfg so unpatched rc.0 still compiles).

- MSRV bumped from 1.88.0 -> 1.91.0 (iroh-relay rc.0's MSRV). Repo's
  rust-toolchain.toml already pins 1.95.0 so builds are unaffected; the
  bump just keeps Cargo.toml honest with the actual transitive floor.
- README updated: 'Rust 1.88+' -> 'Rust 1.91+'.
- crates/sprout-relay/Cargo.toml: added
  iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
  plus the patched-iroh-relay feature.

Tests (rustc 1.95, via rust-toolchain.toml; also verified independently
on 1.91.1):
- sprout-relay --lib: 195 -> 206 (+11 iroh_relay tests covering valid
  admission, missing/empty/non-base64/wrong-method/wrong-URL/wrong-kind/
  stale-timestamp denials, and bearer-encoding round-trips).
- cargo clippy --workspace --all-targets -- -D warnings: clean.
- cargo fmt --all -- --check: clean.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-19 15:39:35 -04:00
co-authored by Dawn
parent f4ca099b1c
commit 3ca6918078
6 changed files with 1793 additions and 16 deletions
Generated
+1366 -14
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@ resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.88.0"
rust-version = "1.91.0"
license = "Apache-2.0"
repository = "https://github.com/sprout-rs/sprout"
+1 -1
View File
@@ -79,7 +79,7 @@ Agents are colleagues, not haunted cron jobs.
## Quick start
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.88+, Node 24+, pnpm 10+, `just`).
You'll need [Docker](https://docs.docker.com/get-docker/) and [Hermit](https://cashapp.github.io/hermit/) (or Rust 1.91+, Node 24+, pnpm 10+, `just`).
**Once:**
```bash
+5
View File
@@ -58,9 +58,14 @@ url = { workspace = true }
moka = { workspace = true }
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
iroh-relay = { version = "=1.0.0-rc.0", features = ["server"] }
[features]
dev = ["sprout-auth/dev"]
# Enables APIs that only exist on a locally-patched fork of iroh-relay
# (notably the per-client max lifetime hook used by Step 3). Off by default
# so the published rc.0 crate compiles unmodified.
patched-iroh-relay = []
[dev-dependencies]
sprout-core = { workspace = true, features = ["test-utils"] }
+418
View File
@@ -0,0 +1,418 @@
//! Embedded iroh-relay server, gated by Sprout relay membership.
//!
//! This is the **Step 3** half of the mesh-LLM plan (v6.1). The desktop
//! sidecar connects its iroh endpoint to `iroh_relay_url` advertised in the
//! Sprout NIP-11 document; this module hosts that relay endpoint inside the
//! Sprout process and gates every connection with the same NIP-98 + relay-
//! membership check we already use for HTTP entry points.
//!
//! The result: mesh-LLM QUIC traffic never leaves the relay's trust boundary
//! and **n0's public relays are never in the path**. No subscriptions, no
//! signups, no out-of-band config — relay members get pooled compute "for
//! free" once they install Sprout.
//!
//! # Access flow
//!
//! 1. The iroh client opens a WebSocket to `https://<relay>/iroh/relay`
//! carrying `Authorization: Bearer <base64(NIP-98 event JSON)>` and its
//! proven `EndpointId` (proved by iroh-relay's handshake before we run).
//! 2. iroh-relay calls our [`AccessConfig::Restricted`] callback with the
//! [`ClientRequest`].
//! 3. We verify the NIP-98 event against the canonical relay URL using
//! [`sprout_auth::nip98_canonical_url`] + [`sprout_auth::verify_nip98_event`].
//! Any failure → `Access::Deny`. This proves the connecting pubkey.
//! 4. We run [`crate::api::relay_members::check_relay_membership`] against
//! that pubkey. Anything other than `OpenRelay`/`Member`/`ViaOwner` →
//! `Access::Deny`.
//! 5. We log the bound (NIP-98 pubkey, EndpointId, decision) for audit.
//!
//! No state is cached: every connection re-runs steps 3 + 4. The cost is one
//! Schnorr verify and 1-2 DB reads per connection, which is negligible
//! versus the QUIC + model traffic that follows.
//!
//! # Patched-fork hooks
//!
//! Upstream iroh-relay rc.0 does not expose a per-client maximum-lifetime
//! hook. A locally-patched fork (`upstream PR C` in the plan) adds one so we
//! can force re-auth every N minutes. We isolate that wiring behind
//! [`cfg(feature = "patched-iroh-relay")`] so the unpatched crate compiles
//! cleanly.
use std::net::SocketAddr;
use std::sync::Arc;
use iroh_relay::server::{
Access, AccessConfig, ClientRequest, RelayConfig, Server, ServerConfig, SpawnError,
};
use tracing::{debug, info, warn};
use crate::api::relay_members::{check_relay_membership, MembershipDecision};
use crate::state::AppState;
/// Path component appended to `iroh_relay_public_url` for the access check.
///
/// Iroh's WebSocket upgrade always hits `/relay`; if the relay is reverse-
/// proxied under a path prefix (e.g. `https://host/iroh`), the full canonical
/// URL is `https://host/iroh/relay`. The NIP-98 signer and verifier both
/// compute this via [`sprout_auth::nip98_canonical_url`].
pub const IROH_RELAY_PATH: &str = "/relay";
/// HTTP method bound into NIP-98 events for iroh-relay connection auth.
const NIP98_METHOD: &str = "GET";
/// Handle returned by [`spawn`] — dropping it stops the server.
pub struct IrohRelayHandle {
/// The bound HTTP address (resolved if the caller passed port 0).
pub http_addr: Option<SocketAddr>,
/// The bound HTTPS address, if TLS was configured.
pub https_addr: Option<SocketAddr>,
_server: Server,
}
/// Spawn an embedded iroh-relay bound to `bind_addr`, gated by Sprout's
/// NIP-98 + relay-membership check.
///
/// Returns `Ok(None)` if `state.config.iroh_relay_public_url` is not set:
/// without a stable public URL the NIP-98 `u`-tag can't be canonicalised, so
/// hosting an iroh-relay endpoint would just produce an undebuggable storm
/// of `URL mismatch` denials. We surface that as "not enabled" instead.
pub async fn spawn(
state: Arc<AppState>,
bind_addr: SocketAddr,
) -> Result<Option<IrohRelayHandle>, SpawnError> {
let Some(public_url) = state.config.iroh_relay_public_url.clone() else {
info!("SPROUT_IROH_RELAY_PUBLIC_URL not set — embedded iroh-relay disabled");
return Ok(None);
};
let canonical_url = match sprout_auth::nip98_canonical_url(&public_url, IROH_RELAY_PATH) {
Some(u) => u,
None => {
warn!(
public_url = %public_url,
"SPROUT_IROH_RELAY_PUBLIC_URL is not a parseable URL — iroh-relay disabled",
);
return Ok(None);
}
};
info!(
bind_addr = %bind_addr,
canonical_url = %canonical_url,
"spawning embedded iroh-relay",
);
let access = build_access_config(state.clone(), canonical_url);
let mut relay = RelayConfig::new(bind_addr);
relay.access = access;
let mut cfg = ServerConfig::default();
cfg.relay = Some(relay);
let server = Server::spawn(cfg).await?;
Ok(Some(IrohRelayHandle {
http_addr: server.http_addr(),
https_addr: server.https_addr(),
_server: server,
}))
}
/// Build the [`AccessConfig::Restricted`] callback that gates every
/// iroh-relay connection on (NIP-98 ∧ relay-membership).
fn build_access_config(state: Arc<AppState>, canonical_url: String) -> AccessConfig {
AccessConfig::Restricted(Box::new(move |request: &ClientRequest| {
let state = state.clone();
let canonical_url = canonical_url.clone();
let endpoint_id = request.endpoint_id();
let auth_token = request.auth_token();
Box::pin(async move {
match decide(&state, &canonical_url, auth_token.as_deref()).await {
Decision::Allow { pubkey, owner } => {
debug!(
endpoint = %endpoint_id,
pubkey = %pubkey,
via_owner = ?owner,
"iroh-relay admission allowed",
);
Access::Allow
}
Decision::Deny(reason) => {
debug!(
endpoint = %endpoint_id,
reason = %reason,
"iroh-relay admission denied",
);
Access::Deny
}
}
})
}))
}
/// Internal decision type for the access callback — kept separate so it's
/// straightforward to unit-test [`decide`] without spinning up a full server.
#[derive(Debug)]
enum Decision {
/// Connection should be admitted.
Allow {
/// The NIP-98-proven pubkey of the connecting client.
pubkey: nostr::PublicKey,
/// `Some(owner)` if admission was via NIP-OA delegation.
owner: Option<nostr::PublicKey>,
},
/// Connection should be rejected, with a debug-only reason string.
Deny(String),
}
/// Pure-logic admission decision, decoupled from iroh-relay's types so it
/// can be unit-tested with a real [`AppState`] and a synthetic bearer token.
async fn decide(state: &AppState, canonical_url: &str, auth_token: Option<&str>) -> Decision {
// Step 1+2+3 — extract and verify the NIP-98 bearer to recover the
// Nostr pubkey. This sub-function is unit-testable in isolation.
let pubkey = match verify_bearer(canonical_url, auth_token) {
Ok(pk) => pk,
Err(reason) => return Decision::Deny(reason),
};
// Step 4 — now and only now, run the membership check. We pass the NIP-98
// pubkey bytes, not the iroh `EndpointId` — the latter is just an
// anonymous network identifier; membership is on Nostr identity.
match check_relay_membership(state, &pubkey.to_bytes(), None).await {
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Decision::Allow {
pubkey,
owner: None,
},
Ok(MembershipDecision::ViaOwner(owner)) => Decision::Allow {
pubkey,
owner: Some(owner),
},
Ok(MembershipDecision::Denied) => Decision::Deny(format!("not a relay member: {}", pubkey)),
Err(e) => {
// Infrastructure failure. Fail closed.
warn!("iroh-relay membership check infra error: {e}");
Decision::Deny(format!("membership check infra error: {e}"))
}
}
}
/// Decode + verify the bearer token, returning the proven Nostr pubkey.
///
/// Fail-closed on:
/// - missing/empty token,
/// - non-base64,
/// - non-UTF-8 JSON,
/// - any NIP-98 verification failure (wrong kind, bad signature, stale
/// timestamp, URL mismatch, method mismatch, payload mismatch).
///
/// The returned `String` is a debug-only deny reason; do not forward to
/// clients (some failures distinguish "what" from "why" in ways we don't
/// want to leak).
fn verify_bearer(
canonical_url: &str,
auth_token: Option<&str>,
) -> Result<nostr::PublicKey, String> {
let token = match auth_token {
Some(t) if !t.is_empty() => t,
_ => return Err("missing or empty bearer token".to_string()),
};
let json =
decode_bearer(token).ok_or_else(|| "bearer token is not valid base64".to_string())?;
sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None)
.map_err(|e| format!("NIP-98 verification failed: {e}"))
}
/// Decode a NIP-98 bearer token. NIP-98 specifies base64 over the JSON event;
/// some signers use URL-safe encoding and/or omit padding, so we accept both.
fn decode_bearer(token: &str) -> Option<String> {
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine;
let trimmed = token.trim();
for engine in [&STANDARD, &URL_SAFE, &STANDARD_NO_PAD, &URL_SAFE_NO_PAD] {
if let Ok(bytes) = engine.decode(trimmed) {
if let Ok(s) = String::from_utf8(bytes) {
return Some(s);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use sprout_auth::nip98_canonical_url;
/// Build a signed NIP-98 event JSON for the given canonical URL.
fn signed_event_json(keys: &Keys, canonical_url: &str, method: &str) -> String {
let event = EventBuilder::new(
Kind::HttpAuth,
"",
vec![
Tag::parse(&["u", canonical_url]).unwrap(),
Tag::parse(&["method", method]).unwrap(),
],
)
.custom_created_at(Timestamp::now())
.sign_with_keys(keys)
.unwrap();
serde_json::to_string(&event).unwrap()
}
fn bearer(json: &str) -> String {
STANDARD.encode(json)
}
fn canonical() -> String {
nip98_canonical_url("https://relay.example.com/iroh", IROH_RELAY_PATH).unwrap()
}
// ── Bearer verification ──────────────────────────────────────────────
#[test]
fn verify_bearer_accepts_valid_nip98() {
let keys = Keys::generate();
let url = canonical();
let json = signed_event_json(&keys, &url, NIP98_METHOD);
let token = bearer(&json);
let result = verify_bearer(&url, Some(&token));
assert!(result.is_ok(), "expected accept, got {result:?}");
assert_eq!(result.unwrap(), keys.public_key());
}
#[test]
fn verify_bearer_rejects_missing_token() {
let url = canonical();
let result = verify_bearer(&url, None);
assert!(matches!(result, Err(ref e) if e.contains("missing")));
}
#[test]
fn verify_bearer_rejects_empty_token() {
let url = canonical();
let result = verify_bearer(&url, Some(""));
assert!(matches!(result, Err(ref e) if e.contains("missing")));
}
#[test]
fn verify_bearer_rejects_non_base64() {
let url = canonical();
let result = verify_bearer(&url, Some("not!!!base64!!!"));
assert!(matches!(result, Err(ref e) if e.contains("base64")));
}
#[test]
fn verify_bearer_rejects_wrong_method() {
// NIP-98 event signed for POST but the iroh-relay handshake is GET.
// The bearer must not be accepted with the wrong method.
let keys = Keys::generate();
let url = canonical();
let json = signed_event_json(&keys, &url, "POST");
let token = bearer(&json);
let result = verify_bearer(&url, Some(&token));
assert!(
matches!(result, Err(ref e) if e.contains("method")),
"expected method-mismatch denial, got {result:?}",
);
}
#[test]
fn verify_bearer_rejects_wrong_url() {
// Event signed for a DIFFERENT relay URL must not authorize access
// to *this* relay. This is the property that breaks if the canonical
// helper drifts between signer and verifier.
let keys = Keys::generate();
let other_url =
nip98_canonical_url("https://other-relay.example.com/iroh", IROH_RELAY_PATH).unwrap();
let json = signed_event_json(&keys, &other_url, NIP98_METHOD);
let token = bearer(&json);
let result = verify_bearer(&canonical(), Some(&token));
assert!(
matches!(result, Err(ref e) if e.contains("URL")),
"expected URL-mismatch denial, got {result:?}",
);
}
#[test]
fn verify_bearer_rejects_wrong_kind() {
let keys = Keys::generate();
let url = canonical();
// Build a kind:1 (text note) event instead of kind:27235 — should fail.
let event = EventBuilder::new(
Kind::TextNote,
"",
vec![
Tag::parse(&["u", &url]).unwrap(),
Tag::parse(&["method", NIP98_METHOD]).unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let token = bearer(&serde_json::to_string(&event).unwrap());
let result = verify_bearer(&url, Some(&token));
assert!(
matches!(result, Err(ref e) if e.contains("kind")),
"expected kind-mismatch denial, got {result:?}",
);
}
#[test]
fn verify_bearer_rejects_stale_timestamp() {
let keys = Keys::generate();
let url = canonical();
let event = EventBuilder::new(
Kind::HttpAuth,
"",
vec![
Tag::parse(&["u", &url]).unwrap(),
Tag::parse(&["method", NIP98_METHOD]).unwrap(),
],
)
// Two hours in the past — well outside the ±60s NIP-98 tolerance.
.custom_created_at(Timestamp::from(Timestamp::now().as_u64() - 7200))
.sign_with_keys(&keys)
.unwrap();
let token = bearer(&serde_json::to_string(&event).unwrap());
let result = verify_bearer(&url, Some(&token));
assert!(
matches!(result, Err(ref e) if e.contains("timestamp")),
"expected timestamp denial, got {result:?}",
);
}
// ── Bearer decoding ──────────────────────────────────────────────────
#[test]
fn decode_bearer_accepts_standard() {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
let payload = r#"{"hello":"world"}"#;
let token = STANDARD.encode(payload);
assert_eq!(decode_bearer(&token).as_deref(), Some(payload));
}
#[test]
fn decode_bearer_accepts_url_safe_no_pad() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let payload = r#"{"hello":"world"}"#;
let token = URL_SAFE_NO_PAD.encode(payload);
assert_eq!(decode_bearer(&token).as_deref(), Some(payload));
}
#[test]
fn decode_bearer_rejects_garbage() {
assert!(decode_bearer("not base64 at all !!!").is_none());
}
}
+2
View File
@@ -14,6 +14,8 @@ pub mod connection;
pub mod error;
/// WebSocket message handlers for NIP-01 client commands.
pub mod handlers;
/// Embedded iroh-relay endpoint, gated by Sprout relay membership.
pub mod iroh_relay;
/// Prometheus metrics: recorder, upkeep, HTTP middleware.
pub mod metrics;
/// NIP-11 relay information document.