mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
7fbdacb03f
commit
a299cd6fff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user