mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
fix(identity): close auth boundary gaps
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
@@ -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<Arc<crate::state::AppState>> {
|
||||
bridge_handler_test_state_with_corporate_identity(false).await
|
||||
}
|
||||
|
||||
async fn bridge_handler_test_state_with_corporate_identity(
|
||||
require_corporate_identity: bool,
|
||||
) -> Option<Arc<crate::state::AppState>> {
|
||||
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(
|
||||
|
||||
@@ -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<Arc<AppState>> 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<AppState> {
|
||||
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<AppState> {
|
||||
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<AppState> {
|
||||
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<Tag>) -> 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();
|
||||
|
||||
@@ -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::<RawJwtClaims>(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::<RawJwtClaims>(
|
||||
&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();
|
||||
|
||||
Reference in New Issue
Block a user