diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index c3e6ed112..628691a0f 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -48,6 +48,9 @@ pub(crate) const MAX_PIXELS: u64 = 64 * 1024 * 1024; pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; /// Connect + read timeout for URL fetches. const FETCH_TIMEOUT: Duration = Duration::from_secs(10); +/// Lifetime of a Blossom `t=get` read token for relay media fetches. +/// Matches the desktop client's `MEDIA_GET_AUTH_EXPIRY_SECS`. +const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; /// Build the decoder allocation cap. Centralised so the resize path uses the /// same value tests can reason about. @@ -221,23 +224,126 @@ fn decode_data_url(src: &str) -> Result, String> { .map_err(|e| format!("data: URL base64 decode failed: {e}")) } +/// True when `target` is a relay-hosted Blossom media URL: an http(s) URL +/// whose path is under `/media/` and whose authority (host + effective port) +/// matches the configured relay URL. The relay URL may use ws/wss schemes — +/// the url crate's known default ports (ws=80/wss=443) make those compare +/// equal to their http/https counterparts. +/// +/// Safety contract: this is the *only* gate that decides whether we attach a +/// signed auth header. It must never match arbitrary third-party origins, +/// where the bearer token would leak. +fn is_relay_media_url(target: &reqwest::Url, relay: &reqwest::Url) -> bool { + if !matches!(target.scheme(), "http" | "https") { + return false; + } + if !target.path().starts_with("/media/") { + return false; + } + target.host_str().is_some() + && target.host_str() == relay.host_str() + && target.port_or_known_default() == relay.port_or_known_default() +} + +/// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to +/// `authority` (`host[:port]`), and return the full `Authorization` header +/// value. Mirrors the desktop client's signer: kind 24242, `t=get`, +/// `expiration`, `server` tag — no `x` tag (BUD-01 allows x OR server). +fn sign_media_get_auth(keys: &nostr::Keys, authority: &str) -> Result { + use nostr::{EventBuilder, JsonUtil, Kind, Tag, Timestamp}; + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(["t", "get"]).map_err(|e| e.to_string())?, + Tag::parse([ + "expiration", + &(now + MEDIA_GET_AUTH_EXPIRY_SECS).to_string(), + ]) + .map_err(|e| e.to_string())?, + Tag::parse(["server", authority]).map_err(|e| e.to_string())?, + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + Ok(format!( + "Nostr {}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + )) +} + +/// `host[:port]` for the `server` tag — explicit port only, matching what the +/// relay binds from the Host header. +fn server_authority(url: &reqwest::Url) -> Option { + let host = url.host_str()?; + Some(match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +/// Mint a `t=get` Authorization header for `url` when it is relay-hosted +/// media and `BUZZ_PRIVATE_KEY` is available; `None` otherwise. +/// +/// Fail-open by design: while the relay's media-read-auth flag is off, an +/// unauthenticated request still succeeds, so a missing/invalid key degrades +/// to an unsigned fetch instead of an error. Once the flag is on, the fetch +/// 403s and the error path below names the missing key. +fn relay_media_get_auth(url: &reqwest::Url) -> Option { + let relay = std::env::var("BUZZ_RELAY_URL").ok()?; + let relay = reqwest::Url::parse(&relay).ok()?; + if !is_relay_media_url(url, &relay) { + return None; + } + let key = std::env::var("BUZZ_PRIVATE_KEY").ok()?; + let keys = match nostr::Keys::parse(&key) { + Ok(k) => k, + Err(e) => { + tracing::warn!("BUZZ_PRIVATE_KEY invalid; fetching relay media unauthenticated: {e}"); + return None; + } + }; + let authority = server_authority(url)?; + match sign_media_get_auth(&keys, &authority) { + Ok(header) => Some(header), + Err(e) => { + tracing::warn!("media get auth signing failed; fetching unauthenticated: {e}"); + None + } + } +} + /// Fetch an http(s) URL with a streaming read and a hard byte cap. /// Refuses up-front if `Content-Length` advertises more than the cap. +/// Relay-hosted `/media/` URLs get a signed Blossom `t=get` header when +/// `BUZZ_RELAY_URL` + `BUZZ_PRIVATE_KEY` are configured. async fn fetch_url(url: &str) -> Result, ErrorData> { let client = reqwest::Client::builder() .connect_timeout(FETCH_TIMEOUT) .timeout(FETCH_TIMEOUT) .build() .map_err(|e| ErrorData::internal_error(format!("http client init failed: {e}"), None))?; - let resp = client - .get(url) + let parsed = reqwest::Url::parse(url) + .map_err(|e| invalid_params(format!("invalid URL: {url} ({e})")))?; + let auth = relay_media_get_auth(&parsed); + let mut req = client.get(parsed); + let authed = auth.is_some(); + if let Some(header) = auth { + req = req.header("Authorization", header); + } + let resp = req .send() .await .map_err(|e| ErrorData::internal_error(format!("fetch failed: {url} ({e})"), None))?; if !resp.status().is_success() { + let status = resp.status(); + if matches!(status.as_u16(), 401 | 403) && !authed { + return Err(invalid_params(format!( + "fetch {url} returned HTTP {status} — this relay requires authenticated media \ + reads; set BUZZ_PRIVATE_KEY (and BUZZ_RELAY_URL) to a member identity" + ))); + } return Err(invalid_params(format!( - "fetch {url} returned HTTP {}", - resp.status() + "fetch {url} returned HTTP {status}" ))); } if let Some(len) = resp.content_length() { @@ -934,4 +1040,83 @@ mod tests { assert_eq!(sniff_mime(&webp).unwrap(), "image/webp"); sniff_mime(b"not-an-image").unwrap_err(); } + + fn u(s: &str) -> reqwest::Url { + reqwest::Url::parse(s).unwrap() + } + + #[test] + fn relay_media_url_matches_relay_host_only() { + let relay = u("http://relay.example.com:3000"); + // Exact authority + /media/ path → match. + assert!(is_relay_media_url( + &u("http://relay.example.com:3000/media/abc.png"), + &relay + )); + // Different host / port / path → no match (token must not leak). + assert!(!is_relay_media_url( + &u("http://evil.example.com:3000/media/abc.png"), + &relay + )); + assert!(!is_relay_media_url( + &u("http://relay.example.com:4000/media/abc.png"), + &relay + )); + assert!(!is_relay_media_url( + &u("http://relay.example.com:3000/other/abc.png"), + &relay + )); + // Path prefix must be a real segment boundary under /media/. + assert!(!is_relay_media_url( + &u("http://relay.example.com:3000/mediafake/abc.png"), + &relay + )); + } + + #[test] + fn relay_media_url_ws_scheme_and_default_ports() { + // wss relay ↔ https media on default ports compare equal. + assert!(is_relay_media_url( + &u("https://relay.example.com/media/abc.png"), + &u("wss://relay.example.com") + )); + assert!(is_relay_media_url( + &u("http://localhost:3000/media/abc.png"), + &u("ws://localhost:3000") + )); + // http media against a wss (443) relay must NOT match. + assert!(!is_relay_media_url( + &u("http://relay.example.com/media/abc.png"), + &u("wss://relay.example.com") + )); + } + + #[test] + fn media_get_auth_header_shape() { + use nostr::JsonUtil; + let keys = nostr::Keys::generate(); + let header = sign_media_get_auth(&keys, "localhost:3000").unwrap(); + let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); + let json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .unwrap(); + let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); + + assert_eq!(event.kind, nostr::Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("get")); + assert_eq!(tag("server").as_deref(), Some("localhost:3000")); + // Server-scoped token: no x tag (BUD-01 allows x OR server). + assert!(tag("x").is_none()); + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = nostr::Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + MEDIA_GET_AUTH_EXPIRY_SECS); + } } diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 9838d0685..e7ed87d40 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -163,6 +163,73 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { Ok(mime) } +/// Lifetime of a Blossom `t=get` read token. Ten minutes keeps a token alive +/// across a video's range-request stream while staying well inside the +/// server's `created_at` freshness window (3600s, matching upload). +pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; + +/// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to the +/// relay's authority, and return the full `Authorization` header value. +/// +/// Server-scoped (a `server` tag, no `x` tag): one token authorizes reads of +/// any blob on that host for its lifetime, which keeps avatar-grid bursts and +/// video range requests cheap. This is deliberately broader than per-blob +/// scoping and is safe only because the relay still enforces NIP-43 +/// membership on the verified pubkey — and because callers only attach this +/// header to requests bound for the relay origin itself. +pub(crate) fn sign_blossom_get_auth_header( + keys: &Keys, + base_url: &str, + expiry_secs: u64, +) -> Result { + let server = extract_server_authority(base_url) + .ok_or_else(|| "cannot derive server authority from relay URL".to_string())?; + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(vec!["t", "get"]).map_err(|e| e.to_string())?, + Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) + .map_err(|e| e.to_string())?, + Tag::parse(vec!["server".to_string(), server]).map_err(|e| e.to_string())?, + ]; + let event = EventBuilder::new(Kind::from(24242), "Get buzz-media") + .tags(tags) + .sign_with_keys(keys) + .map_err(|e| e.to_string())?; + Ok(format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(event.as_json().as_bytes()) + )) +} + +/// Mint a `t=get` Authorization header value for a relay media fetch, or +/// `None` when signing is unavailable (identity in recovery mode). +/// +/// Fail-open by design: while the relay's `BUZZ_REQUIRE_MEDIA_READ_AUTH` flag +/// is off, an unauthenticated request still succeeds, so degrading to no +/// header (instead of erroring) keeps media rendering during key recovery. +/// Once the flag is on, these requests will 403 — the correct outcome for an +/// identity that can't prove membership. +/// +/// Safety contract: callers must only attach the returned header to URLs +/// constructed from (or validated against) the app's own relay base URL — +/// never to third-party origins, where the bearer token would leak. +pub(crate) fn mint_media_get_auth(state: &AppState, base_url: &str) -> Option { + let keys = match state.signing_keys() { + Ok(k) => k, + Err(e) => { + eprintln!("buzz-desktop: media get auth unavailable (unsigned request): {e}"); + return None; + } + }; + match sign_blossom_get_auth_header(&keys, base_url, MEDIA_GET_AUTH_EXPIRY_SECS) { + Ok(header) => Some(header), + Err(e) => { + eprintln!("buzz-desktop: media get auth signing failed (unsigned request): {e}"); + None + } + } +} + fn sign_blossom_upload_auth( keys: &Keys, sha256: &str, @@ -596,6 +663,38 @@ mod tests { assert_eq!(extract_server_authority(""), None); } + #[test] + fn test_sign_blossom_get_auth_header_shape() { + let keys = Keys::generate(); + let header = sign_blossom_get_auth_header(&keys, "http://localhost:3000", 600).unwrap(); + let b64 = header.strip_prefix("Nostr ").expect("Nostr scheme prefix"); + let json = URL_SAFE_NO_PAD.decode(b64).unwrap(); + let event = nostr::Event::from_json(std::str::from_utf8(&json).unwrap()).unwrap(); + + assert_eq!(event.kind, Kind::from(24242)); + event.verify().expect("valid signature"); + + let tag = |name: &str| -> Option { + event.tags.iter().find_map(|t| { + let v = t.as_slice(); + (v.first().map(String::as_str) == Some(name)).then(|| v[1].clone()) + }) + }; + assert_eq!(tag("t").as_deref(), Some("get")); + assert_eq!(tag("server").as_deref(), Some("localhost:3000")); + // Server-scoped token: no x tag (BUD-01 allows x OR server). + assert!(tag("x").is_none()); + let expiration: u64 = tag("expiration").unwrap().parse().unwrap(); + let now = Timestamp::now().as_secs(); + assert!(expiration > now && expiration <= now + 600); + } + + #[test] + fn test_sign_blossom_get_auth_header_invalid_base_url() { + let keys = Keys::generate(); + assert!(sign_blossom_get_auth_header(&keys, "not-a-url", 600).is_err()); + } + #[test] fn test_detect_and_validate_mime_jpeg() { // Minimal JPEG: SOI + EOI diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 1ccae86ba..dcc57819b 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -4,7 +4,7 @@ use tauri::State; use crate::app_state::AppState; use crate::commands::export_util::save_bytes_with_dialog; -use crate::commands::media::{detect_and_validate_mime, sanitize_filename}; +use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename}; use crate::commands::{ personas::{ decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC, @@ -271,13 +271,17 @@ async fn fetch_blob_bytes_with_cap( cap: u64, ) -> Result, String> { // Fetch bytes via the app's HTTP client (goes through WARP tunnel). - let resp = state - .http_client - .get(url) - .timeout(DOWNLOAD_TIMEOUT) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let mut req = state.http_client.get(url).timeout(DOWNLOAD_TIMEOUT); + + // Every caller pre-validates `url` against the relay origin via + // `validate_download_url`, satisfying the mint_media_get_auth safety + // contract (the token never leaves the relay origin). + let relay_base = relay_api_base_url_with_override(state); + if let Some(auth) = mint_media_get_auth(state, &relay_base) { + req = req.header("authorization", auth); + } + + let resp = req.send().await.map_err(|e| classify_request_error(&e))?; if !resp.status().is_success() { return Err(relay_error_message(resp).await); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 8607102a7..5ee19eca4 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -19,7 +19,7 @@ mod identity; mod identity_archive; mod legacy_storage; mod link_preview; -mod media; +pub(crate) mod media; mod media_download; mod media_transcode; #[cfg(feature = "mesh-llm")] diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 10a02d947..760111cde 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -11,6 +11,7 @@ use tauri::{http, Manager}; use tokio::net::TcpListener; use crate::app_state::AppState; +use crate::commands::media::mint_media_get_auth; use crate::relay; /// Defense-in-depth cap: refuse to buffer responses larger than this into RAM. @@ -56,6 +57,12 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> .get(&upstream_url) .timeout(std::time::Duration::from_secs(120)); + // `upstream_url` is always `{relay base}{path}`, so the token can't reach + // a third-party origin (mint_media_get_auth safety contract). + if let Some(auth) = mint_media_get_auth(&app_state, &base_url) { + upstream = upstream.header("authorization", auth); + } + if let Some(range) = req.headers().get("range") { if let Ok(v) = range.to_str() { upstream = upstream.header("range", v); @@ -177,6 +184,13 @@ pub async fn handle_buzz_media( .http_client .get(&upstream_url) .timeout(std::time::Duration::from_secs(60)); + + // `upstream_url` is always `{relay base}{path}`, so the token can't reach + // a third-party origin (mint_media_get_auth safety contract). + if let Some(auth) = mint_media_get_auth(&state, &base) { + upstream = upstream.header("authorization", auth); + } + if let Some(range) = request.headers().get("range") { if let Ok(v) = range.to_str() { upstream = upstream.header("range", v);