Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 102 additions & 27 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,28 @@ pub(crate) async fn enforce_http_admission(
}
}

/// Values retained from an already-verified bridge authentication event.
#[derive(Debug)]
pub(crate) struct VerifiedBridgeAuth {
pub(crate) pubkey: nostr::PublicKey,
pub(crate) event_id_bytes: [u8; 32],
pub(crate) signed_created_at: Option<u64>,
}

type BridgeAuthResult = Result<VerifiedBridgeAuth, (StatusCode, Json<Value>)>;

/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode).
///
/// Returns the authenticated public key and an event ID for replay detection.
/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern).
/// Returns the authenticated public key, an event ID for replay detection, and
/// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is
/// a zero hash and the timestamp is absent.
pub(crate) fn verify_bridge_auth(
headers: &HeaderMap,
method: &str,
url: &str,
body: Option<&[u8]>,
require_auth_token: bool,
) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json<Value>)> {
) -> BridgeAuthResult {
verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false)
}

Expand All @@ -76,7 +87,7 @@ pub(crate) fn verify_bridge_auth_with_options(
body: Option<&[u8]>,
require_auth_token: bool,
require_payload: bool,
) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json<Value>)> {
) -> BridgeAuthResult {
// Try NIP-98 first (Authorization: Nostr <base64>)
if let Some(auth_str) = headers
.get("authorization")
Expand Down Expand Up @@ -111,7 +122,11 @@ pub(crate) fn verify_bridge_auth_with_options(
let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body)
.map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?;

return Ok((pubkey, event_id_bytes));
return Ok(VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at: Some(event.created_at.as_secs()),
});
}

// Dev-mode fallback: X-Pubkey header (only when require_auth_token is false)
Expand All @@ -120,7 +135,11 @@ pub(crate) fn verify_bridge_auth_with_options(
let pubkey = nostr::PublicKey::from_hex(hex_val)
.map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?;
// Zero event ID — no replay detection needed for dev mode
return Ok((pubkey, [0u8; 32]));
return Ok(VerifiedBridgeAuth {
pubkey,
event_id_bytes: [0u8; 32],
signed_created_at: None,
});
}
}

Expand Down Expand Up @@ -723,7 +742,11 @@ pub async fn submit_event(
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events");
let (pubkey, event_id_bytes) = verify_bridge_auth(
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = verify_bridge_auth(
&headers,
"POST",
&url,
Expand All @@ -736,8 +759,16 @@ pub async fn submit_event(
// runs inside the helper. The thin wrapper here owns the single terminal
// attribution line so it fires for every outcome, including admission/
// replay/membership failures that previously returned before any log fired.
let outcome =
submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await;
let outcome = submit_event_authed(
&state,
&tenant,
&headers,
&body,
pubkey,
event_id_bytes,
signed_created_at,
)
.await;

match &outcome {
SubmitOutcome::Ok { accepted, kind, .. } => {
Expand Down Expand Up @@ -846,6 +877,7 @@ async fn submit_event_authed(
body: &[u8],
pubkey: nostr::PublicKey,
event_id_bytes: [u8; 32],
signed_auth_created_at: Option<u64>,
) -> SubmitOutcome {
// Admission and replay checks fire before body parse — a 429 or replay
// reject on a malformed body must still be attributed.
Expand Down Expand Up @@ -888,18 +920,23 @@ async fn submit_event_authed(
};

// Enforce relay membership (with NIP-OA fallback via x-auth-tag header).
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
let nip_oa_owner = match super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
.await
{
Ok(owner) => owner.or_else(|| {
if !state.config.require_relay_membership {
super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag)
super::relay_members::extract_nip_oa_owner(
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
} else {
None
}
Expand Down Expand Up @@ -994,7 +1031,11 @@ pub async fn query_events(
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query");
let (pubkey, event_id_bytes) = verify_bridge_auth(
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = verify_bridge_auth(
&headers,
"POST",
&url,
Expand All @@ -1007,8 +1048,16 @@ pub async fn query_events(
// helper. The single terminal attribution line fires here from the Result
// so every outcome — including admission/replay/membership failures that
// previously returned before any log — is attributed.
let result =
query_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await;
let result = query_events_authed(
&state,
&tenant,
&headers,
&body,
pubkey,
event_id_bytes,
signed_created_at,
)
.await;
match &result {
Ok(Json(Value::Array(events))) => {
tracing::info!(
Expand Down Expand Up @@ -1044,17 +1093,19 @@ async fn query_events_authed(
body: &[u8],
pubkey: nostr::PublicKey,
event_id_bytes: [u8; 32],
signed_auth_created_at: Option<u64>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
enforce_http_admission(state, tenant, &pubkey).await?;
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());
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
.await?;

Expand Down Expand Up @@ -1523,7 +1574,11 @@ pub async fn count_events(
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count");
let (pubkey, event_id_bytes) = verify_bridge_auth(
let VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = verify_bridge_auth(
&headers,
"POST",
&url,
Expand All @@ -1536,8 +1591,16 @@ pub async fn count_events(
// helper. The single terminal attribution line fires here from the Result
// so every outcome — including admission/replay/membership failures that
// previously returned before any log — is attributed.
let result =
count_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await;
let result = count_events_authed(
&state,
&tenant,
&headers,
&body,
pubkey,
event_id_bytes,
signed_created_at,
)
.await;
match &result {
Ok(Json(value)) => {
let count = value.get("count").and_then(Value::as_u64);
Expand Down Expand Up @@ -1571,17 +1634,19 @@ async fn count_events_authed(
body: &[u8],
pubkey: nostr::PublicKey,
event_id_bytes: [u8; 32],
signed_auth_created_at: Option<u64>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
enforce_http_admission(state, tenant, &pubkey).await?;
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());
let auth_tag = super::relay_members::extract_auth_tag_header(headers);
super::relay_members::enforce_relay_membership(
state,
tenant.community(),
&pubkey_bytes,
auth_tag,
signed_auth_created_at,
)
.await?;

Expand Down Expand Up @@ -2309,8 +2374,11 @@ 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 VerifiedBridgeAuth {
pubkey,
event_id_bytes,
..
} = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?;
check_nip98_replay(state, &tenant, event_id_bytes).await?;
let pubkey_bytes = pubkey.to_bytes().to_vec();

Expand Down Expand Up @@ -2921,14 +2989,21 @@ mod tests {
let tenant_a = fresh_tenant("host-a.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events");

let (pubkey, _event_id_bytes) =
verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect("matching-host NIP-98 event must verify");
let VerifiedBridgeAuth {
pubkey,
signed_created_at,
..
} = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect("matching-host NIP-98 event must verify");
assert_eq!(
pubkey,
keys.public_key(),
"returned pubkey must be the signer's"
);
assert!(
signed_created_at.is_some(),
"verified NIP-98 auth must retain its signed timestamp"
);
}

/// Mirror of the query-reconstruction `authorize_moderation_read` performs
Expand Down Expand Up @@ -2970,7 +3045,7 @@ mod tests {
Some("limit=20&status=open"),
);

let (pubkey, _event_id_bytes) =
let VerifiedBridgeAuth { pubkey, .. } =
verify_bridge_auth(&headers, "GET", &expected_url, None, true)
.expect("query-bearing moderation read must verify against the same query");
assert_eq!(pubkey, keys.public_key());
Expand Down Expand Up @@ -3027,7 +3102,7 @@ mod tests {
Some("limit=20"),
);

let (pubkey, _event_id_bytes) =
let VerifiedBridgeAuth { pubkey, .. } =
verify_bridge_auth(&headers, "GET", &expected_url, None, true)
.expect("audit query-bearing read must verify");
assert_eq!(pubkey, keys.public_key());
Expand All @@ -3052,7 +3127,7 @@ mod tests {
);
assert_eq!(expected_url, "https://host-a.example/moderation/restricted");

let (pubkey, _event_id_bytes) =
let VerifiedBridgeAuth { pubkey, .. } =
verify_bridge_auth(&headers, "GET", &expected_url, None, true)
.expect("query-less restricted read must verify against the bare path");
assert_eq!(pubkey, keys.public_key());
Expand Down
11 changes: 7 additions & 4 deletions crates/buzz-relay/src/api/gifs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ async fn authenticate(
})?;

let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options(
let bridge::VerifiedBridgeAuth {
pubkey,
event_id_bytes,
signed_created_at,
} = bridge::verify_bridge_auth_with_options(
headers,
"POST",
&expected_url,
Expand All @@ -152,9 +156,8 @@ async fn authenticate(
state,
tenant.community(),
&pubkey.to_bytes(),
headers
.get("x-auth-tag")
.and_then(|value| value.to_str().ok()),
relay_members::extract_auth_tag_header(headers),
signed_created_at,
)
.await?;

Expand Down
Loading
Loading