fix(identity): close auth boundary gaps

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-07-29 11:55:16 -05:00
parent cb61ed5dbe
commit bc2a16ff39
3 changed files with 229 additions and 29 deletions

View File

@@ -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(

View File

@@ -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();

View File

@@ -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();