feat(desktop,dev-mcp): attach Blossom t=get auth to relay media reads

Desktop: mint a server-scoped kind-24242 t=get Authorization header
(BUD-01: server tag, no x tag; 600s expiration) from the app's signing
keys and attach it in the localhost media proxy, the buzz-media://
scheme handler, and the media download/import fetch path. Fail-open to
an unsigned request when signing keys are unavailable (recovery mode),
so media keeps rendering while the relay's read-auth flag is off.

Dev MCP: view_image detects relay-hosted /media/ URLs (host + effective
port must match BUZZ_RELAY_URL; ws/wss default ports compare equal to
http/https) and attaches the same t=get header signed from
BUZZ_PRIVATE_KEY. Never signs for non-relay origins, so the bearer
token cannot leak to third parties. An unauthenticated 401/403 names
the missing key in the error.

Client lanes for the authenticated-media-GET rollout; the relay-side
verifier (BUZZ_REQUIRE_MEDIA_READ_AUTH) lands separately.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-15 17:51:53 -04:00
co-authored by Tyler Longwell
parent 7fbdacb03f
commit a299cd6fff
5 changed files with 315 additions and 13 deletions
+189 -4
View File
@@ -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<Vec<u8>, 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<String, String> {
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<String> {
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<String> {
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<Vec<u8>, 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<String> {
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);
}
}
+99
View File
@@ -163,6 +163,73 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
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<String, String> {
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<String> {
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<String> {
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
@@ -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<Vec<u8>, 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);
+1 -1
View File
@@ -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")]
+14
View File
@@ -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<ProxyState>, 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);