From bc2a16ff398b91f542e8639f9c0d75dc3a1c7fb8 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:55:16 -0500 Subject: [PATCH] fix(identity): close auth boundary gaps Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-relay/src/api/bridge.rs | 122 +++++++++++++++++++- crates/buzz-relay/src/api/media.rs | 93 +++++++++++---- crates/buzz-relay/src/corporate_identity.rs | 43 ++++++- 3 files changed, 229 insertions(+), 29 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 422efdcbd7..4ffaa21a99 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -127,6 +127,13 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +/// Corporate identity enrollment must always start from cryptographic proof of +/// the Nostr key. The development-only `X-Pubkey` fallback is caller-controlled +/// and therefore cannot safely participate in a durable identity binding. +fn bridge_requires_nip98(require_auth_token: bool, require_corporate_identity: bool) -> bool { + require_auth_token || require_corporate_identity +} + /// Check NIP-98 replay and record the event ID atomically. /// /// The correctness boundary is the shared, community-scoped Redis seen-set on @@ -665,7 +672,10 @@ pub async fn submit_event( "POST", &url, Some(&body), - state.config.require_auth_token, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), )?; let pubkey_hex = pubkey.to_hex(); @@ -941,7 +951,10 @@ pub async fn query_events( "POST", &url, Some(&body), - state.config.require_auth_token, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), )?; let pubkey_hex = pubkey.to_hex(); @@ -1385,7 +1398,10 @@ pub async fn count_events( "POST", &url, Some(&body), - state.config.require_auth_token, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), )?; let pubkey_hex = pubkey.to_hex(); @@ -2120,11 +2136,22 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let (pubkey, event_id_bytes) = verify_bridge_auth( + headers, + "GET", + &url, + None, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), + )?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + enforce_bridge_corporate_identity(state, &tenant, headers, pubkey, auth_tag).await?; + crate::handlers::moderation_authz::authorize_moderation_action( &tenant, state, @@ -2300,6 +2327,33 @@ mod tests { .to_bytes() } + #[test] + fn corporate_identity_disables_x_pubkey_bridge_fallback() { + let keys = Keys::generate(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-pubkey", + keys.public_key() + .to_hex() + .parse() + .expect("valid pubkey header"), + ); + + assert!(!bridge_requires_nip98(false, false)); + assert!(bridge_requires_nip98(true, false)); + assert!(bridge_requires_nip98(false, true)); + + let (status, _) = verify_bridge_auth( + &headers, + "POST", + "https://relay.example/events", + Some(b"{}"), + bridge_requires_nip98(false, true), + ) + .expect_err("corporate identity enrollment must require a signed NIP-98 event"); + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + #[test] fn bridge_detects_mixed_search_and_non_search_filters() { let filters = vec![ @@ -3403,6 +3457,12 @@ mod tests { /// /// Returns `None` when local Postgres is not reachable. async fn bridge_handler_test_state() -> Option> { + bridge_handler_test_state_with_corporate_identity(false).await + } + + async fn bridge_handler_test_state_with_corporate_identity( + require_corporate_identity: bool, + ) -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = TEST_DB_URL.to_string(); // Use the real local Redis so enforce_http_admission can pass. @@ -3411,6 +3471,12 @@ mod tests { config.relay_url = "wss://bridge-test.local".to_string(); config.require_auth_token = false; config.require_relay_membership = false; + config.corporate_identity.require = require_corporate_identity; + if require_corporate_identity { + config.corporate_identity.jwks_uri = "http://127.0.0.1:9/jwks".to_string(); + config.corporate_identity.issuer = "https://idp.example".to_string(); + config.corporate_identity.audience = "buzz-relay".to_string(); + } let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; let db = buzz_db::Db::from_pool(pool.clone()); @@ -3447,6 +3513,52 @@ mod tests { Some(Arc::new(state)) } + #[test] + #[ignore = "requires Postgres"] + fn moderation_reads_require_corporate_identity_after_nip98_proof() { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + let state = rt + .block_on(bridge_handler_test_state_with_corporate_identity(true)) + .expect("local Postgres not reachable"); + let host = format!("bridge-moderation-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let signed_url = format!("https://{host}/moderation/reports"); + let event_json = build_nip98_event_json(&keys, &signed_url, "GET"); + let auth = nip98_auth_headers(&event_json) + .get(header::AUTHORIZATION) + .cloned() + .expect("authorization header"); + let response = rt + .block_on( + crate::router::build_router(state).oneshot( + Request::builder() + .method("GET") + .uri("/moderation/reports") + .header(header::HOST, host) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("build request"), + ), + ) + .expect("router oneshot"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "a valid NIP-98 moderator request without an identity JWT must fail before role authorization" + ); + } + /// Drive a single POST /events request through the router and return the /// HTTP status code. async fn post_events( diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 29307d6bf3..8d560371e3 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -63,6 +63,36 @@ struct MediaReadAuth { tenant: TenantContext, } +async fn enforce_media_corporate_identity( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, +) -> Result<(), MediaError> { + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + headers, + &state.config.corporate_identity, + ); + crate::corporate_identity::enforce_corporate_identity( + state, + tenant.community(), + pubkey, + identity_jwt.as_deref(), + auth_tag, + ) + .await + .map(|_| ()) + .map_err(|e| { + tracing::warn!(pubkey = %pubkey.to_hex(), error = %e, "media: corporate identity denied"); + if e.status_code() == StatusCode::UNAUTHORIZED { + MediaError::Unauthorized + } else { + MediaError::RelayMembershipRequired + } + }) +} + const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); struct UploadPermit { @@ -208,26 +238,7 @@ impl FromRequestParts> for AuthenticatedUpload { // media). On open relays (membership disabled) any valid Blossom signer // may upload, matching the WS door's admission policy. let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( - headers, - &state.config.corporate_identity, - ); - crate::corporate_identity::enforce_corporate_identity( - state, - tenant.community(), - auth_event.pubkey, - identity_jwt.as_deref(), - auth_tag, - ) - .await - .map_err(|e| { - tracing::warn!(pubkey = %auth_event.pubkey.to_hex(), error = %e, "media: corporate identity denied"); - if e.status_code() == StatusCode::UNAUTHORIZED { - MediaError::Unauthorized - } else { - MediaError::RelayMembershipRequired - } - })?; + enforce_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?; crate::api::relay_members::enforce_relay_membership( state, @@ -523,6 +534,7 @@ async fn authenticate_media_read( buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + enforce_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?; crate::api::relay_members::enforce_relay_membership( state, tenant.community(), @@ -967,13 +979,26 @@ mod tests { } async fn test_state() -> Arc { - test_state_with_media_get_auth(false).await + test_state_with_media_auth(false, false).await } async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { + test_state_with_media_auth(require_media_get_auth, false).await + } + + async fn test_state_with_media_auth( + require_media_get_auth: bool, + require_corporate_identity: 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.corporate_identity.require = require_corporate_identity; + if require_corporate_identity { + config.corporate_identity.jwks_uri = "http://127.0.0.1:9/jwks".to_string(); + config.corporate_identity.issuer = "https://idp.example".to_string(); + config.corporate_identity.audience = "buzz-relay".to_string(); + } config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; config.media_max_concurrent_uploads = 2; @@ -1025,6 +1050,16 @@ mod tests { .with_state(state) } + async fn media_get_auth_router_with_corporate_identity() -> axum::Router { + let state = test_state_with_media_auth(true, true).await; + axum::Router::new() + .route( + "/media/{sha256_ext}", + axum::routing::get(get_blob).head(head_blob), + ) + .with_state(state) + } + fn media_get_auth_header(keys: &Keys, tags: Vec) -> String { let event = EventBuilder::new(Kind::from(24242), "Get media") .tags(tags) @@ -1098,6 +1133,22 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn protected_media_reads_require_corporate_identity_for_get_and_head() { + let keys = Keys::generate(); + + for method in ["GET", "HEAD"] { + let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); + let response = media_get_auth_router_with_corporate_identity() + .await + .oneshot(media_request(method, Some(auth))) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{method}"); + } + } + #[tokio::test] async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index c79d9abca4..ede0a82e61 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -97,9 +97,7 @@ impl CorporateIdentityService { CorporateIdentityError::InvalidJwt(format!("invalid JWK for kid {kid}: {e}")) })?; - let mut validation = Validation::new(header.alg); - validation.set_issuer(&[self.config.issuer.as_str()]); - validation.set_audience(&[self.config.audience.as_str()]); + let validation = jwt_validation(header.alg, &self.config); let decoded = decode::(token, &decoding_key, &validation) .map_err(|e| CorporateIdentityError::InvalidJwt(e.to_string()))?; @@ -159,6 +157,17 @@ impl CorporateIdentityService { } } +fn jwt_validation(algorithm: Algorithm, config: &CorporateIdentityConfig) -> Validation { + let mut validation = Validation::new(algorithm); + validation.set_issuer(&[config.issuer.as_str()]); + validation.set_audience(&[config.audience.as_str()]); + // jsonwebtoken only validates `aud` when the claim is present unless it is + // also listed as required. A correctly signed token minted for some other + // service must not be able to omit `aud` and enroll a Buzz identity binding. + validation.required_spec_claims.insert("aud".to_string()); + validation +} + /// Outcome of corporate identity enforcement. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CorporateIdentityDecision { @@ -869,6 +878,34 @@ mod tests { assert!(matches!(err, CorporateIdentityError::InvalidJwt(_))); } + #[test] + fn jwt_validation_rejects_token_without_audience_claim() { + let secret = b"test-secret"; + let header = Header::new(Algorithm::HS256); + let token = encode( + &header, + &serde_json::json!({ + "iss": "https://idp.example", + "sub": "user-1", + "email": "user@example.com", + "exp": 4_102_444_800_u64 + }), + &EncodingKey::from_secret(secret), + ) + .expect("encode test JWT"); + + let err = decode::( + &token, + &DecodingKey::from_secret(secret), + &jwt_validation(Algorithm::HS256, &test_config()), + ) + .expect_err("a token without aud must not enroll an identity binding"); + assert!( + err.to_string().contains("aud"), + "unexpected validation error: {err}" + ); + } + #[test] fn extracts_bearer_token_from_comma_list_header() { let config = test_config();