diff --git a/.env.example b/.env.example index b9bfcada0e..0f7bbba6f1 100644 --- a/.env.example +++ b/.env.example @@ -102,11 +102,10 @@ BUZZ_S3_ADDRESSING_STYLE=path # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 -# Require Blossom t=get auth and relay membership for GET/HEAD /media/*. -# Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. -# BUZZ_REQUIRE_MEDIA_GET_AUTH=false -# Legacy alias accepted by the relay while rollout docs catch up: -# BUZZ_REQUIRE_MEDIA_READ_AUTH=false +# GET/HEAD /media/* always require Blossom t=get auth and relay membership. +# BUZZ_REQUIRE_MEDIA_GET_AUTH and BUZZ_REQUIRE_MEDIA_READ_AUTH are no longer +# read; setting either (including to false) changes nothing and the relay warns +# about it at startup. # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705a..299fc9efe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -768,6 +768,19 @@ jobs: env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr + - name: Media read-auth e2e + # Reads require kind:24242 `t=get` auth, so these binaries are the only + # coverage that a real relay rejects bare reads and honours host- and + # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so + # the lane never ran; select it here, where MinIO and the seeded + # 'localhost:3000' community already exist. + # --no-fail-fast: without it cargo stops after the first failing binary, + # so one broken case hides every later binary's result. + run: | + cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture + env: + RELAY_URL: ws://localhost:3000 + RELAY_HTTP_URL: http://localhost:3000 - name: Upload relay logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/TESTING.md b/TESTING.md index 764b86d408..7c107da575 100644 --- a/TESTING.md +++ b/TESTING.md @@ -277,7 +277,6 @@ out of the box with `just setup` or `just relay`. Common overrides: | `REDIS_URL` | `redis://localhost:6379` | | | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | -| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. | | `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..a2f3640bde 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -493,10 +493,6 @@ async fn authenticate_media_read( ) -> Result { let tenant = bind_media_read_tenant(state, headers).await?; - if !state.config.require_media_get_auth { - return Ok(MediaReadAuth { tenant }); - } - let auth_event = extract_blossom_auth(headers)?; let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; @@ -514,12 +510,8 @@ async fn authenticate_media_read( Ok(MediaReadAuth { tenant }) } -fn blob_cache_control(require_auth: bool) -> &'static str { - if require_auth { - "private, max-age=31536000, immutable" - } else { - "public, max-age=31536000, immutable" - } +fn blob_cache_control() -> &'static str { + "private, max-age=31536000, immutable" } /// Whether a path-segment extension is a safe token. @@ -623,7 +615,7 @@ pub(crate) async fn serve_blob_for_tenant( req_headers: &HeaderMap, ) -> Result { validate_media_path(sha256_ext)?; - let cache_control = blob_cache_control(state.config.require_media_get_auth); + let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -801,10 +793,9 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let require_media_get_auth = state.config.require_media_get_auth; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; let tenant = media_auth.tenant; - let cache_control = blob_cache_control(require_media_get_auth); + let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -946,13 +937,8 @@ mod tests { } async fn test_state() -> Arc { - test_state_with_media_get_auth(false).await - } - - async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; - config.require_media_get_auth = require_media_get_auth; config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; config.media_max_concurrent_uploads = 2; @@ -994,8 +980,8 @@ mod tests { Arc::new(state) } - async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router { - let state = test_state_with_media_get_auth(require_media_get_auth).await; + async fn media_get_auth_router() -> axum::Router { + let state = test_state().await; axum::Router::new() .route( "/media/{sha256_ext}", @@ -1041,20 +1027,9 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() { - let response = media_get_auth_router(false) - .await - .oneshot(media_request("GET", None)) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() { + async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() { for method in ["GET", "HEAD"] { - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request(method, None)) .await @@ -1065,10 +1040,10 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() { + async fn media_read_with_valid_server_scoped_token_reaches_sidecar_gate() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request("GET", Some(auth))) .await @@ -1078,7 +1053,7 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { + async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); let now = Timestamp::now().as_secs(); let expiration = (now + 300).to_string(); @@ -1102,7 +1077,7 @@ mod tests { for tags in cases { let auth = media_get_auth_header(&keys, tags); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(media_request("GET", Some(auth))) .await @@ -1119,7 +1094,7 @@ mod tests { } #[tokio::test] - async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() { + async fn media_read_accepts_range_header_only_after_auth() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); let mut request = media_request("GET", Some(auth)); @@ -1127,7 +1102,7 @@ mod tests { .headers_mut() .insert(header::RANGE, "bytes=0-0".parse().expect("range header")); - let response = media_get_auth_router(true) + let response = media_get_auth_router() .await .oneshot(request) .await diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index dd50973d03..037c6b1dd3 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -227,10 +227,6 @@ pub struct Config { /// Maximum media upload starts accepted from one pubkey per minute. pub media_uploads_per_minute: u32, - /// Require Blossom kind:24242 `t=get` auth plus relay membership before - /// serving media GET/HEAD. Default off for staged client rollout. - pub require_media_get_auth: bool, - /// Whether tamper-evident event/media audit logging is enabled. Defaults to true. /// This does not control the separate `moderation_actions` audit trail. /// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it. @@ -435,6 +431,31 @@ fn ensure_git_path( Ok(git_repo_path) } +/// Env vars that once gated authenticated media reads. +/// +/// `BUZZ_REQUIRE_MEDIA_GET_AUTH` was the real flag; `BUZZ_REQUIRE_MEDIA_READ_AUTH` +/// was documented in `.env.example` as an accepted alias but was never read by +/// the relay. Media reads are now unconditionally authenticated, so both are +/// inert and an operator still setting either — especially to `false` — holds a +/// belief about their deployment that is no longer true. +const INERT_MEDIA_READ_AUTH_VARS: [&str; 2] = [ + "BUZZ_REQUIRE_MEDIA_GET_AUTH", + "BUZZ_REQUIRE_MEDIA_READ_AUTH", +]; + +/// Which of `names` are present, so startup can warn that they do nothing. +/// +/// `lookup` is injected rather than calling `std::env::var` directly: process +/// env is global mutable state, so a test that set real vars would race every +/// other test in the binary. +fn inert_env_vars<'a>(names: &[&'a str], lookup: impl Fn(&str) -> Option) -> Vec<&'a str> { + names + .iter() + .copied() + .filter(|name| lookup(name).is_some()) + .collect() +} + impl Config { /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { @@ -776,14 +797,13 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(30); - let require_media_get_auth = std::env::var("BUZZ_REQUIRE_MEDIA_GET_AUTH") - .map(|v| { - v == "true" - || v == "1" - || v.eq_ignore_ascii_case("yes") - || v.eq_ignore_ascii_case("on") - }) - .unwrap_or(false); + for name in inert_env_vars(&INERT_MEDIA_READ_AUTH_VARS, |n| std::env::var(n).ok()) { + warn!( + "{name} is set but is no longer read — GET/HEAD /media/* always require \ + Blossom t=get auth plus relay membership. Remove it; a value of `false` \ + does not re-open unauthenticated media reads." + ); + } let ephemeral_ttl_override = std::env::var("BUZZ_EPHEMERAL_TTL_OVERRIDE") .ok() @@ -1003,7 +1023,6 @@ impl Config { media_max_concurrent_uploads, media_max_concurrent_uploads_per_pubkey, media_uploads_per_minute, - require_media_get_auth, audit_enabled, ephemeral_ttl_override, git_repo_path, @@ -1035,6 +1054,59 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Look up against a fixed set, standing in for process env. + fn env_of<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + use<'a> { + move |name| { + set.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_string()) + } + } + + /// The case that matters: an operator who pinned the old flag to `false` + /// must be told it is inert, not left believing media reads are still open. + #[test] + fn inert_media_read_auth_vars_are_reported_even_when_false() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[("BUZZ_REQUIRE_MEDIA_GET_AUTH", "false")]), + ); + + assert_eq!(found, vec!["BUZZ_REQUIRE_MEDIA_GET_AUTH"]); + } + + /// `BUZZ_REQUIRE_MEDIA_READ_AUTH` was advertised in `.env.example` as an + /// accepted alias but the relay never read it, so operators may hold it + /// today. It warns too. + #[test] + fn inert_media_read_auth_vars_include_the_documented_alias() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[ + ("BUZZ_REQUIRE_MEDIA_GET_AUTH", "true"), + ("BUZZ_REQUIRE_MEDIA_READ_AUTH", "false"), + ]), + ); + + assert_eq!( + found, + vec![ + "BUZZ_REQUIRE_MEDIA_GET_AUTH", + "BUZZ_REQUIRE_MEDIA_READ_AUTH" + ] + ); + } + + #[test] + fn inert_media_read_auth_vars_stay_quiet_when_unset() { + let found = inert_env_vars( + &INERT_MEDIA_READ_AUTH_VARS, + env_of(&[("BUZZ_REQUIRE_RELAY_MEMBERSHIP", "true")]), + ); + + assert!(found.is_empty(), "unrelated vars must not warn: {found:?}"); + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); @@ -1072,10 +1144,6 @@ mod tests { !config.serve_git_web_gui, "serve_git_web_gui should default to false" ); - assert!( - !config.require_media_get_auth, - "require_media_get_auth should default to false for staged client rollout" - ); assert_eq!( config.media.s3_addressing_style, buzz_media::config::S3AddressingStyle::Path, diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 15002142e4..4c8c8904ac 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -2612,17 +2612,27 @@ mod pubsub_presence_typing { mod media_blossom { use super::*; - /// Obligation: public blob `GET/HEAD /media/{sha256.ext}` stays - /// unauthenticated (N=1 compat, shared CAS bytes). The community boundary is - /// the metadata/descriptor/upload-auth/quota/audit layer: B's private upload - /// metadata/errors must not be observable from A, even when the blob bytes - /// are deduplicated and shared. + /// Obligation: blob `GET/HEAD /media/{sha256.ext}` requires Blossom read auth + /// scoped to the serving host or the blob hash, and the request is bound to the + /// tenant resolved from the request headers. A bare read is rejected before any + /// storage lookup, so the endpoint does not leak blob existence. + /// + /// CAS bytes are still deduplicated across communities, so the boundary is not + /// the bytes: it is the metadata/descriptor/upload-auth/quota/audit layer plus + /// the per-tenant read binding. B's private upload metadata and errors must not + /// be observable from A even when the underlying blob is shared. + /// + /// Known limitation, deferred: relay membership plus knowledge of a hash is + /// sufficient to read a blob. Read auth binds host and tenant, not the channel + /// ACL of the message the blob was attached to. #[tokio::test] #[ignore] async fn media_metadata_boundary_holds_while_blob_bytes_shared() { pending_lane( "buzz-media", - "shared SHA bytes OK; A cannot read B's upload metadata/quota/audit; errors generic", + "reads require host/hash-scoped Blossom auth and bind to the header tenant; \ + bare reads 401 before storage; shared SHA bytes OK; A cannot read B's upload \ + metadata/quota/audit; errors generic", ); } } diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 14001f641c..690fd9c8a5 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -48,6 +48,26 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event for the given sha256. +/// +/// Reads are authenticated unconditionally, so every successful GET/HEAD in this +/// file has to present one of these. The `x` tag is hash-scoped and covers the +/// derived paths too -- the relay matches on the sha256 before the extension, so +/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + /// Build `Authorization: Nostr ` header value. fn blossom_auth_header(event: &nostr::Event) -> String { format!( @@ -144,10 +164,14 @@ async fn test_upload_and_get() { descriptor["dim"], descriptor["blurhash"] ); + // Reads are authenticated, so mint one hash-scoped token for all three below. + let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)); + // GET /media/{sha256}.jpg — bytes must match let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); let get_resp = client .get(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("GET /media/{sha256}.jpg failed"); @@ -162,6 +186,7 @@ async fn test_upload_and_get() { // HEAD /media/{sha256}.jpg — must return 200 with content-type let head_resp = client .head(&get_url) + .header("Authorization", &read_auth) .send() .await .expect("HEAD /media/{sha256}.jpg failed"); @@ -175,6 +200,7 @@ async fn test_upload_and_get() { let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); let thumb_resp = client .get(&thumb_url) + .header("Authorization", &read_auth) .send() .await .expect("GET thumbnail failed"); @@ -293,19 +319,69 @@ async fn test_upload_hash_mismatch_returns_400() { assert_eq!(resp.status(), 401, "hash mismatch must be 401"); } -/// GET a sha256 that was never uploaded must return 404. +/// GET an authenticated sha256 that was never uploaded must return 404. +/// +/// The token has to be valid for the 404 to be reachable at all: authentication +/// runs before the storage lookup, so a bare request is rejected with 401 and +/// never distinguishes "missing" from "unauthorized" (see +/// `test_unauthenticated_reads_are_rejected`). #[tokio::test] #[ignore] async fn test_get_nonexistent_returns_404() { let client = http_client(); + let keys = Keys::generate(); let missing_sha256 = "0".repeat(64); let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); - let resp = client.get(&url).send().await.expect("GET failed"); + let resp = client + .get(&url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)), + ) + .send() + .await + .expect("GET failed"); println!("missing blob → {}", resp.status()); assert_eq!(resp.status(), 404, "missing blob must be 404"); } +/// Bare reads are rejected with 401 before any storage lookup. +/// +/// This is the boundary PR #4610 made unconditional: there is no longer a config +/// flag that lets an unauthenticated GET through, so the acceptance lane has to +/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401 +/// here rather than a 404 proves auth runs ahead of the storage lookup and that the +/// endpoint does not leak blob existence to an unauthenticated caller. +#[tokio::test] +#[ignore] +async fn test_unauthenticated_reads_are_rejected() { + let client = http_client(); + let missing_sha256 = "0".repeat(64); + let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url()); + let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url()); + + let get_resp = client.get(&blob_url).send().await.expect("bare GET failed"); + println!("bare GET → {}", get_resp.status()); + assert_eq!(get_resp.status(), 401, "bare GET must be 401"); + + let head_resp = client + .head(&blob_url) + .send() + .await + .expect("bare HEAD failed"); + println!("bare HEAD → {}", head_resp.status()); + assert_eq!(head_resp.status(), 401, "bare HEAD must be 401"); + + let thumb_resp = client + .get(&thumb_url) + .send() + .await + .expect("bare thumbnail GET failed"); + println!("bare thumbnail GET → {}", thumb_resp.status()); + assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401"); +} + /// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var). /// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match. #[tokio::test] @@ -363,7 +439,15 @@ async fn test_upload_real_image() { // GET bytes back and verify let get_url = descriptor["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET failed"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET failed"); assert_eq!(get_resp.status(), 200); let returned = get_resp.bytes().await.unwrap(); assert_eq!( diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 955bd9d6c4..8a9283c040 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -39,6 +39,21 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .unwrap() } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so round-trip GETs must present one of these. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let tags = vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["x", sha256]).unwrap(), + Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -98,15 +113,15 @@ fn tiny_jpeg() -> Vec { } fn tiny_png() -> Vec { - // Valid 2x2 red PNG generated by ffmpeg + // Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped: + // `validate_png_metadata_free` rejects pHYs as an identity channel, so the + // original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only. vec![ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd, - 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, - 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, - 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, - 0xae, 0x42, 0x60, 0x82, + 0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, + 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, + 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ] } @@ -168,9 +183,14 @@ async fn test_upload_png_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".png")); println!("✅ PNG upload: {}", desc["url"]); - // GET back + // GET back — reads are authenticated, so scope a token to the uploaded hash. + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); @@ -192,8 +212,13 @@ async fn test_upload_gif_roundtrip() { assert!(desc["url"].as_str().unwrap().ends_with(".gif")); println!("✅ GIF upload: {}", desc["url"]); + let sha256 = desc["sha256"].as_str().expect("descriptor sha256"); let get = client .get(desc["url"].as_str().unwrap()) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) .send() .await .unwrap(); diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 64a5878f13..2ec0b1e698 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -40,6 +40,23 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .expect("sign blossom auth") } +/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated +/// unconditionally, so blob and range GETs must present one of these -- without it +/// the 206 and 416 range behaviour below would never be reached. +fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event { + let now = Timestamp::now().as_secs(); + let exp_str = (now + 300).to_string(); + let tags = vec![ + Tag::parse(["t", "get"]).expect("t tag"), + Tag::parse(["x", sha256]).expect("x tag"), + Tag::parse(["expiration", &exp_str]).expect("expiration tag"), + ]; + EventBuilder::new(Kind::from(24242), "Get test") + .tags(tags) + .sign_with_keys(keys) + .expect("sign blossom get auth") +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -272,7 +289,15 @@ async fn test_video_upload_and_get() { // GET the blob back let get_url = desc["url"].as_str().unwrap(); - let get_resp = client.get(get_url).send().await.expect("GET blob"); + let get_resp = client + .get(get_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) + .send() + .await + .expect("GET blob"); assert_eq!(get_resp.status(), StatusCode::OK); let body = get_resp.bytes().await.expect("body bytes"); assert_eq!(body.len(), mp4.len()); @@ -345,6 +370,10 @@ async fn test_video_range_request_206() { // Range request: first 100 bytes let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header("Range", "bytes=0-99") .send() .await @@ -389,6 +418,10 @@ async fn test_video_range_request_416() { // Request a range beyond the file size let range_resp = client .get(blob_url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)), + ) .header( "Range", format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000), diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt index b409f4d942..a0dd96a1a4 100644 --- a/deploy/charts/buzz/templates/NOTES.txt +++ b/deploy/charts/buzz/templates/NOTES.txt @@ -62,11 +62,6 @@ {{- if not .Values.relay.requireRelayMembership }} ⚠ relay.requireRelayMembership=false — relay is OPEN. Anyone can publish. {{- end }} -{{- if not .Values.relay.requireMediaGetAuth }} - ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated. - Anyone who learns a media URL/hash can fetch private attachments. Only - use for local development or fully public communities. -{{- end }} {{- if not .Values.migrate.autoMigrate }} ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations. You must run `buzz-admin migrate` against the database before every diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 5c876f7d24..0ad41ac461 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -131,7 +131,6 @@ spec: - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } - { name: BUZZ_ALLOW_NIP_OA_AUTH, value: {{ .Values.relay.allowNipOaAuth | quote }} } - { name: BUZZ_PUBKEY_ALLOWLIST, value: {{ .Values.relay.pubkeyAllowlist | quote }} } {{- if .Values.relay.corsOrigins }} diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index cf08210781..196a4a5303 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -48,17 +48,6 @@ tests: name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: "true" template: templates/deployment.yaml - # Security default: media GET/HEAD reads must be auth-gated out of the - # box. A private attachment must never be publicly readable by URL/hash - # in an unmodified render. If this assertion fails, someone flipped the - # default — treat that as a security regression, not a config tweak. - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_REQUIRE_MEDIA_GET_AUTH - value: "true" - template: templates/deployment.yaml - - it: renders virtual-hosted S3 addressing for providers that require it set: relayUrl: wss://buzz.example.com @@ -85,24 +74,6 @@ tests: value: "virtual" template: templates/deployment.yaml - - it: lets an explicit value opt out of media read auth for dev/public deployments - set: - relayUrl: wss://buzz.example.com - ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" - externalPostgresql.url: postgres://u:p@h:5432/d - externalRedis.url: redis://h:6379 - s3.endpoint: http://minio:9000 - s3.accessKey: a - s3.secretKey: s - relay.requireMediaGetAuth: false - asserts: - - contains: - path: spec.template.spec.containers[0].env - content: - name: BUZZ_REQUIRE_MEDIA_GET_AUTH - value: "false" - template: templates/deployment.yaml - - it: lets an explicit value disable huddle audio in a single-replica render set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index e1e362a531..d3670595b5 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -62,7 +62,6 @@ "drainJitterMs": { "type": "integer", "minimum": 0 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, - "requireMediaGetAuth": { "type": "boolean" }, "allowNipOaAuth": { "type": "boolean" }, "huddleAudioAvailable": { "type": ["boolean", "null"], diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 42b09f1b3e..8131aef432 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -117,12 +117,6 @@ relay: drainJitterMs: 0 requireAuthToken: true requireRelayMembership: true - # Authenticated media reads: relay GET/HEAD /media/* requires Blossom - # kind 24242 t=get plus relay membership. Enabled by default so private - # attachments are never publicly readable by URL/hash. Only set false for - # local development or fully public communities — desktop, mobile, and CLI - # clients all attach read auth. - requireMediaGetAuth: true allowNipOaAuth: true pubkeyAllowlist: false corsOrigins: [] diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 86a91a9842..070381f55e 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -350,11 +350,9 @@ pub(crate) fn sign_blossom_get_auth_header( /// 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_GET_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. +/// When signing is unavailable, callers send no header and the relay rejects +/// the read. This keeps recovery mode from accidentally treating a media URL +/// as a bearer capability. /// /// Safety contract: callers must only attach the returned header to URLs /// constructed from (or validated against) the app's own relay base URL — diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 29a5c35e6a..14c7c196b2 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -668,9 +668,9 @@ pub async fn mint_agent_card( .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, Some(url) if url.starts_with("http://") || url.starts_with("https://") => { // Relay-hosted avatars (kind:0 pictures under the relay's /media/) - // may require Blossom get-auth (`require_media_get_auth`). Mint the - // header ONLY for same-origin URLs so the token never leaves the - // relay (same contract as `media_download.rs`). + // require Blossom get-auth. Mint the header ONLY for same-origin URLs + // so the token never leaves the relay (same contract as + // `media_download.rs`). let relay_base = crate::relay::relay_api_base_url_with_override(&state); let auth = is_same_origin(url, &relay_base) .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..f49cbdd71a 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -54,9 +54,9 @@ sidecar before accessing the shared content-addressed blob. Unknown feedback, unreferenced hashes, malformed paths, and cross-community substitutions all collapse to `404`. -Only `GET` and `HEAD` are routed. Existing community `/media/*` authorization is -unchanged, including `BUZZ_REQUIRE_MEDIA_GET_AUTH`; the browser receives no -Blossom credential or reusable signed URL. Responses are uncached, `nosniff`, +Only `GET` and `HEAD` are routed. Community `/media/*` reads always require +Blossom authorization and relay membership; the browser receives no reusable +signed URL. Responses are uncached, `nosniff`, governed by a restrictive CSP, streamed from object storage, and non-previewable content retains attachment disposition. Successful reads produce a structured trace containing feedback ID, community ID, and attachment hash, but no feedback diff --git a/docs/multi-tenant-conformance.md b/docs/multi-tenant-conformance.md index 3cd56066eb..d8877b5931 100644 --- a/docs/multi-tenant-conformance.md +++ b/docs/multi-tenant-conformance.md @@ -49,7 +49,7 @@ Conformance obligations: | Workflows, runs, approvals, webhooks, schedules | Workflows are channel-scoped or project/channel-global; triggers fire on matching stored events; schedule/webhook/manual triggers create runs; approval tokens are hashed. | Workflow definition's community from `req.community` at create/update; webhook/schedule/manual routes resolve workflow id inside host-derived community. | Community-global workflow namespace; runs/approvals inherit workflow community. | `workflows`, `workflow_runs`, `workflow_approvals` include `community_id`; workflow id/token hash lookups are scoped; trigger event ids are scoped. | Trigger evaluation only sees events in the same community. Webhook URLs include host-derived community; approval token grants cannot act on another community's same hash/id. | Existing workflow APIs and YAML remain unchanged in default community. | Add tests for identical workflow UUID/approval token hash in different communities and schedule execution isolation. | | Search / FTS | Postgres FTS over the `events.search_tsv` generated `tsvector` column (GIN-indexed); searchable rows expose `id`, `content`, `kind`, `pubkey`, optional `channel_id`, `created_at`, tag terms; channel-less scope is `ChannelScope::ChannelLessOnly`; the relay refetches canonical events from Postgres by hit id. | Search query carries `req.community`; searchable rows carry `community_id`. | Community-global search results; operator-global FTS index infrastructure may be shared. | Every search query filters by `community_id`, BitmapAnd-ed with the GIN `@@` probe; refetch by `(community_id, event_id)`. | Every query carries `community_id` plus channel scope. `ChannelLessOnly` means channel-less within the community, not platform global. | One community produces the same search results as today. | Tests for same event id/content in A and B, deletion in A not deleting B. | | Redis pub/sub, presence, typing, and cache invalidation | Event fan-out uses `buzz:channel:{uuid}`; presence uses `buzz:presence:{pubkey}`; typing uses `buzz:typing:{channel_id}`; cache invalidation uses `buzz:cache-invalidate`. | Pub/sub calls receive `TenantContext` and derive keys from `community_id` plus channel/pubkey. | Pub/sub and presence are community-global; Redis deployment is operator-global shared infrastructure. | Redis keys include community: `buzz:{community}:channel:{uuid}`, `buzz:{community}:presence:{pubkey}`, `buzz:{community}:typing:{channel_id}`, and community-aware cache invalidation payloads/channels. | Cross-node fan-out must not deliver events to subscriptions in another community. Same pubkey can be online/away differently in two communities. Cache drops only affect same-community membership/visibility caches unless explicitly all-community operator maintenance. | Single-community can preserve existing key names only if deployment is isolated; shared multi-tenant Redis must use the prefixed form. | Add tests for same pubkey presence in two communities and same channel UUID collision in two communities. | -| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; public `GET/HEAD /media/{sha256.ext}` serves blobs; upload audit has `channel_id = None`. | Upload request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community. | Decide whether unauthenticated blob `GET` remains intentionally public; if not, reads need host-scoped auth/visibility checks. | +| Media / Blossom / S3 | Authenticated uploads return content-addressed descriptors; `GET/HEAD /media/{sha256.ext}` requires a Blossom `t=get` auth event scoped to the serving host or the blob hash and binds the read to the header-resolved tenant, so a bare read is rejected before any storage lookup; upload audit has `channel_id = None`. | Upload and read request host provides `req.community`; Blossom/NIP-98 auth URL host must agree. | Blob CAS bytes may be operator-global shared storage; metadata, authorization, quotas, audit, and visibility are community-global. | Media metadata/audit rows include `community_id`; if object keys stay SHA-addressed, any per-community policy lives outside the raw blob key. | Upload/read authorization uses community context. Shared hash bytes are allowed only as dedup/storage optimization; metadata/errors must not reveal another community's private upload. | Existing media URLs keep working for default community, but clients must now present read auth; there is no config flag that restores unauthenticated reads. | Resolved: blob reads are authenticated and host/tenant-scoped, not public. Remaining gap, deferred: a read is not gated on the channel ACL of the message the blob was attached to, so relay membership plus a known hash is sufficient. | | Git hosting / NIP-34 / object storage | Smart HTTP at `/git/{owner}/{repo}` hydrates from S3 object pointers; NIP-34 repo announcements use `d=repo-id`; pointer key is `repos/{owner}/{repo}/pointer`; git push emits kind:30618. | Git HTTP host gives `req.community`; NIP-98 URL and repo announcement community must agree. | Community-global repo namespace and NIP-34 state; pack/manifests CAS objects may be operator-global if pointers are scoped. | Pointer/name keys include community, e.g. `repos/{community}/{owner}/{repo}/pointer`; NIP-34 replaceable coords include `community_id`; any repo-name registry is `(community_id, owner, repo)` or `(community_id, repo)` per product rule. | Clone/push/read policy resolves repo and branch protections only inside the host community. Git hook policy callback carries community and rejects mismatches. | Existing clone URLs and repo ids work under the default community; object-store migration can move pointers under default prefix without changing git clients. | Add tests for same owner/repo in two communities and push in A not advancing B pointer. | | Mesh, agents, ACP/MCP, and CLI | Agents/CLI connect to a relay URL and use WS/REST; mesh/pairing/presence/status events are regular signed relay events. | The relay URL/host configured in the agent/CLI session selects community. | Agent membership, persona/profile, presence, jobs, memory events, and mesh status are community-global unless a future operator mesh plane is explicitly separate. | Any persisted agent profile/job/mesh status rows/events use `community_id`; Redis/presence/search keys follow the same community scoping. | A portable key may join multiple communities, but memberships, DMs, profiles, jobs, and presence do not bleed across them. | Existing `BUZZ_RELAY_URL` continues to select the one default community. | Add CLI/ACP smoke tests against two hosts using same key with different memberships/profile. | | Audit log and observability | One hash-chain audit log records event/channel/auth/media actions; errors are sanitized before reaching clients. | Every tenant-observable audit entry is labeled with `req.community` or inherited community from the object being acted on. | Community-global audit chains; operator metrics/log aggregation may be platform-global only if tenant labels are bounded and access-controlled. | `audit_log` key/sequence/head includes `community_id`; error/audit projection tables include `community_id`; uniqueness is `(community_id, seq)` and `(community_id, hash)` as appropriate. | Audit reads verify only one community chain. Error strings must not include cross-community IDs, constraint names, or existence facts. | Single-community audit verification still traverses one chain. | Eva owns model edits here; infra lane must ensure media/git/token/search rows emit community-labeled audit entries. |