mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
## Summary
Relays that enforce federated identity (NIP-FI) reject every protected
`buzz` CLI request with `401`. The CLI never sends
`Nostr-Federated-Identity`. This PR gives the CLI a credential chain,
lets it present NIP-FI evidence, and lets it take its identity from Buzz
desktop. Relays without NIP-FI behave exactly as before.
**Credential chain** (`crates/buzz-cli/src/credentials.rs`)
- Raw `Keys` are replaced by `Credentials`, which provide the public
key, signing through nostr's `NostrSigner`, NIP-44, and the NIP-OA auth
tag. NIP-FI evidence comes through `evidence(relay)`.
- The explicit key (`--private-key` / `BUZZ_PRIVATE_KEY`) is the first
link. A later link can add a signer that holds no local key (a desktop
provider, for example) without reshaping callers.
- Operations that need key material rather than a signature are
purpose-bound methods instead of a key accessor:
- `engram_d_tag` (the NIP-AE HMAC over the conversation key)
- the relay-scoped GIF customer id (random and fixed for the process
when there is no local key)
- `buzz mem` now encrypts, decrypts and derives the `d` tag through the
provider. Invalid ciphertext skips an entry; a signer failure aborts, so
an unavailable credential never reads as empty memory.
`buzz_core::engram` gains the composable steps behind it: `encode_body`,
`event_builder`, `validate_envelope` and `check_d_tag`. `build_event`
and `validate_and_decrypt` are unchanged wrappers, so `buzz-acp` and
desktop callers don't move.
- Agent-management drafts (`agent_management.rs`) encrypt and sign
through the provider.
**Evidence on every protected request**
(`crates/buzz-cli/src/client.rs`)
- A single `authorize` helper gets the NIP-FI assertion first, then
signs the proof just before sending, so a slow mint cannot age it past
NIP-98's 60 s window. It sets these on every protected route:
`Authorization` (NIP-98 or Blossom kind:24242), `x-auth-tag`, and
`Nostr-Federated-Identity: Bearer …`. The routes are `/query`, `/count`,
`/events` (stored and moderation), `/upload`, `/media/upload`,
`/media/*`, `get_authed`, `post_json_authed` and
`post_json_once_authed`.
- Exempt routes (`get_public`) are unchanged.
- The WebSocket publish sends the header on the upgrade via
`connect_authenticated_with_headers`. NIP-42 AUTH signs through the
signer (`buzz-ws-client` now takes `NostrSigner`; `&Keys` callers
compile unchanged).
- An upgrade denial is an HTTP response under NIP-FI.
`WsClientError::upgrade_rejection` returns its status and body, which
map like any other relay error, so a `403` still says whether evidence
or authorization was refused. A denial after NIP-42 (such as a key
mismatch) is a 1008 close; `WsClientError::PolicyClose` keeps its reason
and the CLI reports it as an `auth_error`.
- The WebSocket publish caps each network phase as a whole, writes
included: connect, AUTH challenge (with any Pongs), AUTH send plus OK,
EVENT send plus OK, and a best-effort close. NIP-42 signing between
phases is bounded by the signer. The relay drops a socket not signed in
within 5 s, so a sign-in that is throttled or comes back after the
socket closed reconnects and signs the new challenge, at most 3
connections. Each connection fetches current NIP-FI evidence for its
upgrade, and a throttled sign-in is waited out between connections, so a
dead challenge is never signed. EVENT is signed before connecting and
sent once, after AUTH succeeds. Running out is `unavailable` (exit 2,
`retryable:true`).
- Relay traffic follows no redirects: reqwest strips `Authorization`
across origins but not `x-auth-tag` or `Nostr-Federated-Identity`, so a
3xx is returned as a relay error. Credential header values are marked
sensitive.
**Headless evidence source**
(`crates/buzz-cli/src/federated_identity.rs`)
- Set `BUZZ_FEDERATED_IDENTITY_TOKEN` (your own NIP-FA sign-in session:
a human session for your key, not an agent grant) and
`BUZZ_FEDERATED_IDENTITY_ADAPTER` (the adapter base) to enable it. Both
are env only: command lines are visible to other local users.
- The CLI requests an assertion and the adapter mints it, through `POST
{adapter}/v1/identity/assertions` with a NIP-98 proof in
`Nostr-Authorization` (NIP-FA §Relay assertion).
- It checks the returned pubkey and expiry, parses the assertion as a
compact JWS (three base64url segments, JSON header with `alg`, JSON
payload), and requires its `exp` to equal `expires_at`. It caches one
assertion per process, bound to the relay when credentials are resolved,
and re-mints at the earlier of half-life and 30 s before expiry; the
desktop link caches and refreshes its assertion by the same rule.
Concurrent callers share one mint. An HTTP proof is signed again, after
current evidence, when the assertion changed while it was signed or the
evidence check aged it 30 s or more, up to 3 rounds, then fails as
`unavailable` (retryable).
- Compared with the separate rules before: a 300 s assertion now
refreshes at 150 s (was 270 s) on both links, a 40 s adapter assertion
at 10 s (was 20 s), and a 20 s desktop assertion is kept for 10 s
instead of fetched on every lookup. Desktop assertions now need a
readable JWS header with `alg` and a non-empty signature segment. An
unsupported headless relay URL now fails when credentials are resolved.
- The token goes only to the adapter origin (https, or http on
loopback), never follows a redirect, and never appears in errors or
`Debug`.
- A mint retries network errors, `429` and any `5xx` up to three
attempts with jittered backoff (or the adapter's `Retry-After` on
`429`/`503`, as delta-seconds or any HTTP-date form, capped at 30 s), on
every path (reads, post-once, moderation, stored events, WebSocket). It
then fails as `CliError::Adapter` (`adapter_error`, exit 2,
`retryable:true`). Relay retry loops never repeat it. A stored write
keeps `delivery_unknown` when any earlier attempt may have been
delivered, so a later adapter failure never invites a re-sign. Relay
mutation retries are otherwise unchanged.
- An explicit port 80 relay URL (read numerically from the authority, on
any scheme) is a usage error: `wss://host` means port 443, and NIP-FA
has no relay on port 80.
- A refusal is never retried: a non-transient status with an unreadable
or truncated body is still a refusal.
- Each NIP-FA denial code maps to an `auth_error` with a specific
message.
- These settings belong to the explicit key: without one they are not
read, and when the desktop link wins a warning names them.
- If an enforcing relay answers `401` and no assertion was sent, the CLI
returns an `auth_error` that names both settings, or, for a desktop
user, says to sign in to the community in Buzz. A desktop user's `403`
hint no longer mentions `BUZZ_AUTH_TAG`.
**What got simpler**
- About ten request sites built `Authorization` and `x-auth-tag`
themselves. Five copies of the 401/403 error-mapping block are now one
`relay_error`.
- The `sign_nip98` / `sign_blossom_*` helpers are gone. Proofs are a
three-case `Proof` enum consumed by `authorize`.
- `BuzzClient::keys()` (raw secret access from every command) is
removed. Commands use `public_key()`, `sign_event` or a purpose-bound
credentials method.
**Desktop credential provider**
(`crates/buzz-cli/src/desktop_provider.rs`,
`docs/local-credential-provider.md`)
Buzz desktop is a credential provider in the chain, after an explicit
key:
1. An inherited `BUZZ_CREDENTIAL_PROVIDER` token (set by the desktop for
the agents it launches). It decides the identity: when it is revoked,
not bound to the relay or unreachable, the command fails. A paired
token, which acts as the human, is never tried. That keeps a
well-behaved CLI from acting as the owner by accident; it is not a
boundary against an agent that can run its own commands as the owner's
OS user, and the contract says so with the mitigations.
2. Otherwise, a paired token stored in the OS keychain (`keyring`; macOS
native keychain, Linux Secret Service; no file fallback). A desktop that
answers `denied` or `unavailable` fails the command; only an unreachable
or unconfirmed advertisement is skipped. Revoked entries are forgotten.
New commands: `buzz auth desktop pair` and `buzz auth desktop unpair`.
The help no longer marks `BUZZ_PRIVATE_KEY` as required.
The CLI talks to the desktop over the loopback contract in
`docs/local-credential-provider.md` (NIP-46-shaped envelopes,
`Buzz-Credential-Provider` version header, same-user check on the
listening socket for paired and inherited tokens, via `/usr/sbin/lsof`
on macOS, given 5 s; a check that runs out of time is a retryable
`unavailable`, a wrong owner is refused). Every response's `id` must
match its request, error envelopes included. Paired tokens live only in
the keychain, survive desktop restarts, and are harness-neutral for
agents.
- **Relay binding:** tokens bind the whole relay URL (`http`/`https`
read as `ws`/`wss`, lowercase host, no default port, `/` for an empty
path; path and query count). The CLI sends that URL in `pair` and
`federated_identity`; NIP-FA audience canonicalization is the desktop's.
- **Rate limits:** a `rate_limited` answer refuses before acting, so the
CLI re-sends after `retry_after` (capped at 30 s), up to four times,
except a NIP-42 sign-in, whose wait goes to the WebSocket reconnect. A
re-sent NIP-42, NIP-98 or Blossom proof is re-dated (new id, Blossom
`expiration` moved with it); content events are re-sent exactly. If it
is still throttled, the command fails as `rate_limited` (exit 2,
`retryable:true`), not `auth_error`, whether the throttled call was
NIP-98, event signing, NIP-42 or encryption. Transport failures are
never re-sent, and relay commands are never replayed. A `buzz mem` list
that is throttled partway through completes.
- **Memory:** a `nip44_decrypt` answered with `invalid_request` is
invalid ciphertext, so `buzz mem` skips that entry (NIP-AE filtering);
any other provider failure aborts.
- **Keychain changes:** each change reloads the map under a short
`flock` on `~/.buzz-foundation/cli-pairings.lock` (directory forced to
`0700`, lock opened with `O_NOFOLLOW`) and changes only its own
instance; the lock is never held across the approval wait. An unreadable
entry is refused, never overwritten; `unpair` with no instance clears
it.
### Related
Related:
[block/buzz-app#2260](https://github.com/block/buzz-app/pull/2260) (the
desktop side of the provider contract)
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Will Pfleger <wpfleger@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
501 lines
18 KiB
Rust
501 lines
18 KiB
Rust
use std::collections::VecDeque;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use nostr::{Event, NostrSigner, Tag};
|
|
use serde_json::{json, Value};
|
|
use tokio::time::timeout;
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
use tokio_tungstenite::tungstenite::http::HeaderMap;
|
|
use tokio_tungstenite::{
|
|
client_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream,
|
|
};
|
|
use tracing::debug;
|
|
|
|
use crate::error::WsClientError;
|
|
use crate::message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage};
|
|
|
|
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
|
|
|
/// Seconds to wait for the relay to send the NIP-42 AUTH challenge after connecting.
|
|
pub const AUTH_CHALLENGE_TIMEOUT_SECS: u64 = 20;
|
|
|
|
/// Seconds to wait for the relay's OK response to the AUTH event.
|
|
pub const AUTH_OK_TIMEOUT_SECS: u64 = 20;
|
|
|
|
/// Seconds to wait for the relay's OK response to a published event.
|
|
pub const PUBLISH_OK_TIMEOUT_SECS: u64 = 30;
|
|
|
|
/// Cap on a graceful close.
|
|
pub const CLOSE_TIMEOUT_SECS: u64 = 5;
|
|
|
|
/// Run one network phase, writes included, within its read `wait` plus a
|
|
/// second, so a read that times out reports its own error first.
|
|
async fn phase<T>(
|
|
wait: Duration,
|
|
io: impl std::future::Future<Output = Result<T, WsClientError>>,
|
|
) -> Result<T, WsClientError> {
|
|
tokio::time::timeout(wait + Duration::from_secs(1), io)
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?
|
|
}
|
|
|
|
/// A NIP-42-capable WebSocket connection to a Nostr relay.
|
|
pub struct NostrWsConnection {
|
|
ws: WsStream,
|
|
/// Messages read while waiting for something else, each with when it
|
|
/// was read.
|
|
buffer: VecDeque<(Instant, RelayMessage)>,
|
|
pending_challenge: Option<String>,
|
|
relay_url: String,
|
|
}
|
|
|
|
impl NostrWsConnection {
|
|
/// Connects to the relay at `url` and performs NIP-42 authentication with `signer`.
|
|
///
|
|
/// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event.
|
|
pub async fn connect_authenticated<S: NostrSigner>(
|
|
url: &str,
|
|
signer: &S,
|
|
auth_tag: Option<&Tag>,
|
|
) -> Result<Self, WsClientError> {
|
|
Self::connect_authenticated_with_headers(url, signer, auth_tag, HeaderMap::new()).await
|
|
}
|
|
|
|
/// [`Self::connect_authenticated`], sending `headers` on the WebSocket upgrade request.
|
|
///
|
|
/// Use this for credentials the relay checks at upgrade time, such as a NIP-FI
|
|
/// `Nostr-Federated-Identity` assertion that must pair with the NIP-42 AUTH key.
|
|
pub async fn connect_authenticated_with_headers<S: NostrSigner>(
|
|
url: &str,
|
|
signer: &S,
|
|
auth_tag: Option<&Tag>,
|
|
headers: HeaderMap,
|
|
) -> Result<Self, WsClientError> {
|
|
let mut conn = Self::connect_with_headers(url, headers).await?;
|
|
conn.authenticate(signer, auth_tag).await?;
|
|
Ok(conn)
|
|
}
|
|
|
|
/// Connects to the relay at `url` without performing authentication.
|
|
pub async fn connect(url: &str) -> Result<Self, WsClientError> {
|
|
Self::connect_with_headers(url, HeaderMap::new()).await
|
|
}
|
|
|
|
/// [`Self::connect`], sending `headers` on the WebSocket upgrade request.
|
|
///
|
|
/// The headers are added after the handshake headers, so supplying a handshake
|
|
/// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade.
|
|
pub async fn connect_with_headers(
|
|
url: &str,
|
|
headers: HeaderMap,
|
|
) -> Result<Self, WsClientError> {
|
|
let parsed = url
|
|
.parse::<url::Url>()
|
|
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
|
|
|
let mut request = parsed.as_str().into_client_request()?;
|
|
request.headers_mut().extend(headers);
|
|
|
|
let (ws, _response) = connect_async(request)
|
|
.await
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
debug!("connected to relay at {url}");
|
|
|
|
Ok(Self::from_stream(ws, url))
|
|
}
|
|
|
|
/// [`Self::connect`], but opens a plaintext TCP connection to `addr`
|
|
/// (`host:port`) instead of resolving the URL's host.
|
|
///
|
|
/// The URL still supplies the `Host` header and NIP-42 relay tag, even when
|
|
/// it is `wss://`, so a client inside the relay's cluster can reach a
|
|
/// community through the relay's plaintext service address and still
|
|
/// authenticate for the community's public URL.
|
|
pub async fn connect_to_addr(url: &str, addr: &str) -> Result<Self, WsClientError> {
|
|
let parsed = url
|
|
.parse::<url::Url>()
|
|
.map_err(|e| WsClientError::Url(e.to_string()))?;
|
|
let request = parsed.as_str().into_client_request()?;
|
|
let tcp = tokio::net::TcpStream::connect(addr)
|
|
.await
|
|
.map_err(|e| WsClientError::WebSocket(e.into()))?;
|
|
let (ws, _response) = client_async(request, MaybeTlsStream::Plain(tcp))
|
|
.await
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
debug!("connected to relay at {url} via {addr}");
|
|
|
|
Ok(Self::from_stream(ws, url))
|
|
}
|
|
|
|
fn from_stream(ws: WsStream, url: &str) -> Self {
|
|
Self {
|
|
ws,
|
|
buffer: VecDeque::new(),
|
|
pending_challenge: None,
|
|
relay_url: url.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Performs NIP-42 authentication using `signer` against the connected relay.
|
|
///
|
|
/// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event.
|
|
pub async fn authenticate<S: NostrSigner>(
|
|
&mut self,
|
|
signer: &S,
|
|
auth_tag: Option<&Tag>,
|
|
) -> Result<(), WsClientError> {
|
|
// Each network phase is capped as a whole, writes (and Pongs sent
|
|
// while reading) included. Signing between them is the signer's to
|
|
// bound: a remote signer may hold it for a person's decision.
|
|
let wait = Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS);
|
|
let challenge = phase(wait, self.wait_for_auth_challenge(wait)).await?;
|
|
|
|
let auth_event = build_auth_event(&challenge, &self.relay_url, signer, auth_tag).await?;
|
|
let event_id = auth_event.id.to_hex();
|
|
|
|
let wait = Duration::from_secs(AUTH_OK_TIMEOUT_SECS);
|
|
let ok = phase(wait, async {
|
|
self.send_raw(&json!(["AUTH", auth_event])).await?;
|
|
self.wait_for_ok(&event_id, wait).await
|
|
})
|
|
.await?;
|
|
if !ok.accepted {
|
|
return Err(WsClientError::AuthFailed(ok.message));
|
|
}
|
|
|
|
debug!("NIP-42 authentication successful");
|
|
Ok(())
|
|
}
|
|
|
|
/// Sends a signed event to the relay and waits for the OK response.
|
|
pub async fn send_event(&mut self, event: Event) -> Result<OkResponse, WsClientError> {
|
|
let event_id = event.id.to_hex();
|
|
let wait = Duration::from_secs(PUBLISH_OK_TIMEOUT_SECS);
|
|
phase(wait, async {
|
|
self.send_raw(&json!(["EVENT", event])).await?;
|
|
self.wait_for_ok(&event_id, wait).await
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Receives the next relay message, waiting up to `timeout_dur`.
|
|
pub async fn next_event(
|
|
&mut self,
|
|
timeout_dur: Duration,
|
|
) -> Result<RelayMessage, WsClientError> {
|
|
Ok(self.next_event_received(timeout_dur).await?.1)
|
|
}
|
|
|
|
/// [`Self::next_event`], with when the message was read off the socket.
|
|
///
|
|
/// A message that arrived while another call, such as
|
|
/// [`Self::send_event`], waited for something else is returned later,
|
|
/// with the instant it arrived rather than the instant it is returned.
|
|
pub async fn next_event_received(
|
|
&mut self,
|
|
timeout_dur: Duration,
|
|
) -> Result<(Instant, RelayMessage), WsClientError> {
|
|
if let Some(entry) = self.buffer.pop_front() {
|
|
return Ok(entry);
|
|
}
|
|
let msg = self.recv_one(timeout_dur).await?;
|
|
Ok((Instant::now(), msg))
|
|
}
|
|
|
|
/// Closes the WebSocket connection gracefully, within [`CLOSE_TIMEOUT_SECS`].
|
|
pub async fn disconnect(mut self) -> Result<(), WsClientError> {
|
|
tokio::time::timeout(Duration::from_secs(CLOSE_TIMEOUT_SECS), self.ws.close(None))
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Sends a raw JSON value as a WebSocket text frame.
|
|
pub async fn send_raw(&mut self, value: &Value) -> Result<(), WsClientError> {
|
|
let text = serde_json::to_string(value)?;
|
|
debug!("→ relay: {text}");
|
|
self.ws.send(Message::Text(text.into())).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn recv_one(&mut self, timeout_dur: Duration) -> Result<RelayMessage, WsClientError> {
|
|
loop {
|
|
let raw = timeout(timeout_dur, self.ws.next())
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?
|
|
.ok_or(WsClientError::ConnectionClosed)?
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
match raw {
|
|
Message::Text(text) => {
|
|
let msg = parse_relay_message(&text)?;
|
|
if let RelayMessage::Auth { ref challenge } = msg {
|
|
self.pending_challenge = Some(challenge.clone());
|
|
}
|
|
return Ok(msg);
|
|
}
|
|
Message::Ping(data) => {
|
|
self.ws.send(Message::Pong(data)).await?;
|
|
}
|
|
Message::Close(frame) => return Err(closed(frame)),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_auth_challenge(
|
|
&mut self,
|
|
timeout_dur: Duration,
|
|
) -> Result<String, WsClientError> {
|
|
if let Some(challenge) = self.pending_challenge.take() {
|
|
return Ok(challenge);
|
|
}
|
|
|
|
if let Some(idx) = self
|
|
.buffer
|
|
.iter()
|
|
.position(|(_, m)| matches!(m, RelayMessage::Auth { .. }))
|
|
{
|
|
match self.buffer.remove(idx).unwrap().1 {
|
|
RelayMessage::Auth { challenge } => return Ok(challenge),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
let deadline = tokio::time::Instant::now() + timeout_dur;
|
|
|
|
loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(tokio::time::Instant::now())
|
|
.unwrap_or(Duration::ZERO);
|
|
|
|
if remaining.is_zero() {
|
|
return Err(WsClientError::NoAuthChallenge);
|
|
}
|
|
|
|
let raw = timeout(remaining, self.ws.next())
|
|
.await
|
|
.map_err(|_| WsClientError::NoAuthChallenge)?
|
|
.ok_or(WsClientError::ConnectionClosed)?
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
match raw {
|
|
Message::Text(text) => {
|
|
let msg = parse_relay_message(&text)?;
|
|
match msg {
|
|
RelayMessage::Auth { challenge } => {
|
|
if challenge.len() > 1024 {
|
|
return Err(WsClientError::AuthFailed(
|
|
"challenge exceeds 1024 bytes".into(),
|
|
));
|
|
}
|
|
return Ok(challenge);
|
|
}
|
|
other => self.buffer.push_back((Instant::now(), other)),
|
|
}
|
|
}
|
|
Message::Ping(data) => {
|
|
self.ws.send(Message::Pong(data)).await?;
|
|
}
|
|
Message::Close(frame) => return Err(closed(frame)),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_ok(
|
|
&mut self,
|
|
event_id: &str,
|
|
timeout_dur: Duration,
|
|
) -> Result<OkResponse, WsClientError> {
|
|
let deadline = tokio::time::Instant::now() + timeout_dur;
|
|
|
|
if let Some(idx) = self
|
|
.buffer
|
|
.iter()
|
|
.position(|(_, m)| matches!(m, RelayMessage::Ok(ok) if ok.event_id == event_id))
|
|
{
|
|
match self.buffer.remove(idx).unwrap().1 {
|
|
RelayMessage::Ok(ok) => return Ok(ok),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
loop {
|
|
let remaining = deadline
|
|
.checked_duration_since(tokio::time::Instant::now())
|
|
.unwrap_or(Duration::ZERO);
|
|
|
|
if remaining.is_zero() {
|
|
return Err(WsClientError::Timeout);
|
|
}
|
|
|
|
let raw = timeout(remaining, self.ws.next())
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?
|
|
.ok_or(WsClientError::ConnectionClosed)?
|
|
.map_err(WsClientError::WebSocket)?;
|
|
|
|
match raw {
|
|
Message::Text(text) => {
|
|
let msg = parse_relay_message(&text)?;
|
|
match msg {
|
|
RelayMessage::Ok(ok) if ok.event_id == event_id => return Ok(ok),
|
|
RelayMessage::Auth { ref challenge } => {
|
|
self.pending_challenge = Some(challenge.clone());
|
|
self.buffer.push_back((Instant::now(), msg));
|
|
}
|
|
other => self.buffer.push_back((Instant::now(), other)),
|
|
}
|
|
}
|
|
Message::Ping(data) => {
|
|
self.ws.send(Message::Pong(data)).await?;
|
|
}
|
|
Message::Close(frame) => return Err(closed(frame)),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One-shot helper: connect, authenticate, send one event, disconnect.
|
|
///
|
|
/// Establishes a fresh WebSocket connection carrying `headers` on the upgrade
|
|
/// (e.g. a NIP-FI `Nostr-Federated-Identity` assertion), completes NIP-42
|
|
/// authentication with `signer`, publishes `event`, waits for the relay's OK
|
|
/// response, then closes the connection. The entire operation is bounded by
|
|
/// `timeout_secs`.
|
|
pub async fn publish_event<S: NostrSigner>(
|
|
relay_url: &str,
|
|
event: Event,
|
|
signer: &S,
|
|
auth_tag: Option<&Tag>,
|
|
headers: HeaderMap,
|
|
timeout_secs: u64,
|
|
) -> Result<OkResponse, WsClientError> {
|
|
let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async {
|
|
let mut conn = NostrWsConnection::connect_authenticated_with_headers(
|
|
relay_url, signer, auth_tag, headers,
|
|
)
|
|
.await?;
|
|
let ok = conn.send_event(event).await?;
|
|
let _ = conn.disconnect().await;
|
|
Ok::<_, WsClientError>(ok)
|
|
})
|
|
.await
|
|
.map_err(|_| WsClientError::Timeout)?;
|
|
result
|
|
}
|
|
|
|
/// The error for a close frame: a policy close keeps its reason, since a
|
|
/// relay denial is not an ordinary connection loss.
|
|
fn closed(frame: Option<tokio_tungstenite::tungstenite::protocol::CloseFrame>) -> WsClientError {
|
|
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
|
match frame {
|
|
Some(frame) if frame.code == CloseCode::Policy => {
|
|
WsClientError::PolicyClose(frame.reason.to_string())
|
|
}
|
|
_ => WsClientError::ConnectionClosed,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn auth_challenge_timeout_meets_floor() {
|
|
const { assert!(AUTH_CHALLENGE_TIMEOUT_SECS >= 20) };
|
|
}
|
|
|
|
#[test]
|
|
fn auth_ok_timeout_meets_floor() {
|
|
const { assert!(AUTH_OK_TIMEOUT_SECS >= 20) };
|
|
}
|
|
|
|
#[test]
|
|
fn publish_ok_timeout_meets_floor() {
|
|
const { assert!(PUBLISH_OK_TIMEOUT_SECS >= 30) };
|
|
}
|
|
|
|
// The handshake callback's `Result<Response, ErrorResponse>` is fixed by tungstenite.
|
|
#[allow(clippy::result_large_err)]
|
|
#[tokio::test]
|
|
async fn connect_with_headers_sends_headers_on_upgrade() {
|
|
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}", listener.local_addr().unwrap());
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut received = None;
|
|
let _ws = tokio_tungstenite::accept_hdr_async(
|
|
stream,
|
|
|request: &Request, response: Response| {
|
|
received = request.headers().get("nostr-federated-identity").cloned();
|
|
Ok(response)
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
received
|
|
});
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
"Nostr-Federated-Identity",
|
|
HeaderValue::from_static("Bearer header.payload.signature"),
|
|
);
|
|
let _conn = NostrWsConnection::connect_with_headers(&url, headers)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
server.await.unwrap(),
|
|
Some(HeaderValue::from_static("Bearer header.payload.signature"))
|
|
);
|
|
}
|
|
|
|
/// A relay that answers NIP-42 AUTH by closing with `code`, as the relay
|
|
/// does on a NIP-FI key mismatch (1008).
|
|
async fn close_after_auth(code: u16) -> WsClientError {
|
|
use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("ws://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
ws.send(Message::Text(r#"["AUTH","challenge"]"#.into()))
|
|
.await
|
|
.unwrap();
|
|
let _auth = ws.next().await;
|
|
ws.send(Message::Close(Some(CloseFrame {
|
|
code: CloseCode::from(code),
|
|
reason: "auth-required: key mismatch".into(),
|
|
})))
|
|
.await
|
|
.unwrap();
|
|
});
|
|
NostrWsConnection::connect_authenticated(&url, &nostr::Keys::generate(), None)
|
|
.await
|
|
.err()
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_policy_close_keeps_its_reason_and_other_closes_do_not() {
|
|
let err = close_after_auth(1008).await;
|
|
assert!(
|
|
matches!(&err, WsClientError::PolicyClose(r) if r.contains("key mismatch")),
|
|
"{err:?}"
|
|
);
|
|
let err = close_after_auth(1000).await;
|
|
assert!(matches!(err, WsClientError::ConnectionClosed), "{err:?}");
|
|
}
|
|
}
|