Nostr protocol client operations for BigBrotr.
Provides client factory, relay connection with SSL fallback, event
broadcasting, and relay validation. Built on top of the
WebSocket transport primitives in
bigbrotr.utils.transport.
Attributes:
-
create_client
(Client)
–
Client factory with optional SOCKS5 proxy and SSL override.
-
connect_relay
(Client)
–
High-level helper with automatic SSL fallback.
-
is_nostr_relay
(bool)
–
Check whether a URL hosts a Nostr relay.
-
broadcast_events
(int)
–
Sign and broadcast events to multiple relays.
Note
The SSL fallback strategy for clearnet relays follows a two-phase
approach: first attempt a fully verified TLS connection, then fall back
to InsecureWebSocketTransport
only if the error is SSL-related and allow_insecure=True. This
ensures security by default while accommodating relays with self-signed
or expired certificates.
Overlay networks (Tor, I2P, Lokinet) always use
nostr_sdk.ConnectionMode.PROXY with a SOCKS5 proxy and do not
attempt SSL fallback, as the overlay itself provides encryption.
See Also
bigbrotr.utils.transport: WebSocket transport
primitives used by this module.
bigbrotr.models.relay.Relay: The relay
model consumed by all connection functions.
bigbrotr.models.constants.NetworkType:
Enum used to select between clearnet and overlay transport strategies.
Examples:
from bigbrotr.utils.protocol import create_client, connect_relay
client = await create_client(keys=my_keys, proxy_url="socks5://tor:9050")
client = await connect_relay(relay, keys=my_keys, timeout=10.0)
Classes
Functions
create_client
async
create_client(
keys: Keys | None = None,
proxy_url: str | None = None,
*,
allow_insecure: bool = False,
) -> Client
Create a Nostr client with optional SOCKS5 proxy and SSL override.
For overlay networks, uses nostr-sdk's built-in proxy via
ConnectionMode.PROXY. For clearnet, uses standard SSL connections
unless allow_insecure is True.
Parameters:
-
keys
(Keys | None, default:
None
)
–
Optional signing keys (None = read-only client).
-
proxy_url
(str | None, default:
None
)
–
SOCKS5 proxy URL for overlay networks (e.g., socks5://tor:9050).
-
allow_insecure
(bool, default:
False
)
–
Returns:
-
Client
–
Configured Client instance (call add_relay() before use).
Note
When a proxy_url hostname is not already an IP address, it is
resolved asynchronously via asyncio.to_thread(socket.gethostbyname)
because nostr-sdk requires a numeric IP for the proxy connection.
Warning
Setting allow_insecure=True bypasses all SSL/TLS certificate
verification. Only use when standard SSL has already been attempted
and failed.
See Also
connect_relay: Higher-level
function that handles connection and automatic SSL fallback.
Source code in src/bigbrotr/utils/protocol.py
| async def create_client(
keys: Keys | None = None,
proxy_url: str | None = None,
*,
allow_insecure: bool = False,
) -> Client:
"""Create a Nostr client with optional SOCKS5 proxy and SSL override.
For overlay networks, uses nostr-sdk's built-in proxy via
``ConnectionMode.PROXY``. For clearnet, uses standard SSL connections
unless ``allow_insecure`` is ``True``.
Args:
keys: Optional signing keys (``None`` = read-only client).
proxy_url: SOCKS5 proxy URL for overlay networks (e.g., ``socks5://tor:9050``).
allow_insecure: If ``True``, bypass SSL certificate verification
using [InsecureWebSocketTransport][bigbrotr.utils.transport.InsecureWebSocketTransport].
Returns:
Configured ``Client`` instance (call ``add_relay()`` before use).
Note:
When a ``proxy_url`` hostname is not already an IP address, it is
resolved asynchronously via ``asyncio.to_thread(socket.gethostbyname)``
because nostr-sdk requires a numeric IP for the proxy connection.
Warning:
Setting ``allow_insecure=True`` bypasses all SSL/TLS certificate
verification. Only use when standard SSL has already been attempted
and failed.
See Also:
[connect_relay][bigbrotr.utils.protocol.connect_relay]: Higher-level
function that handles connection and automatic SSL fallback.
"""
builder = ClientBuilder()
if keys is not None:
signer = NostrSigner.keys(keys)
builder = builder.signer(signer)
if allow_insecure:
transport = InsecureWebSocketTransport()
builder = builder.websocket_transport(transport)
if proxy_url is not None:
parsed = urlparse(proxy_url)
proxy_host = parsed.hostname or "127.0.0.1"
proxy_port = parsed.port or 9050
# nostr-sdk requires an IP address, not a hostname
bare_host = proxy_host.strip("[]")
try:
IPv4Address(bare_host)
except (AddressValueError, ValueError):
try:
IPv6Address(bare_host)
proxy_host = bare_host
except (AddressValueError, ValueError):
proxy_host = await asyncio.to_thread(socket.gethostbyname, proxy_host)
proxy_mode = ConnectionMode.PROXY(proxy_host, proxy_port)
conn = Connection().mode(proxy_mode).target(ConnectionTarget.ONION)
opts = ClientOptions().connection(conn)
builder = builder.opts(opts)
return builder.build()
|
connect_relay
async
connect_relay(
relay: Relay,
keys: Keys | None = None,
proxy_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
*,
allow_insecure: bool = False,
) -> Client
Connect to a relay with automatic SSL fallback for clearnet.
For clearnet relays, tries SSL first and falls back to insecure if allowed.
Overlay networks (Tor/I2P/Loki) require a proxy and use no SSL fallback.
Parameters:
-
relay
(Relay)
–
-
keys
(Keys | None, default:
None
)
–
-
proxy_url
(str | None, default:
None
)
–
SOCKS5 proxy URL (required for overlay networks).
-
timeout
(float, default:
DEFAULT_TIMEOUT
)
–
Connection timeout in seconds.
-
allow_insecure
(bool, default:
False
)
–
If True, fall back to insecure transport on SSL failure.
Returns:
-
Client
–
Connected Client ready for use.
Raises:
-
TimeoutError
–
-
ValueError
–
If overlay relay requested without proxy_url.
-
SSLCertVerificationError
–
If SSL fails and allow_insecure is False.
Note
The clearnet fallback path requires calling uniffi_set_event_loop()
before creating the
InsecureWebSocketTransport,
because the custom transport uses UniFFI callbacks that need access
to the running asyncio event loop.
See Also
create_client: Used for both
the initial SSL-verified attempt and the insecure fallback.
is_nostr_relay: Higher-level
validation that uses this function internally.
Source code in src/bigbrotr/utils/protocol.py
| async def connect_relay(
relay: Relay,
keys: Keys | None = None,
proxy_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT, # noqa: ASYNC109
*,
allow_insecure: bool = False,
) -> Client:
"""Connect to a relay with automatic SSL fallback for clearnet.
For clearnet relays, tries SSL first and falls back to insecure if allowed.
Overlay networks (Tor/I2P/Loki) require a proxy and use no SSL fallback.
Args:
relay: [Relay][bigbrotr.models.relay.Relay] to connect to.
keys: Optional signing keys.
proxy_url: SOCKS5 proxy URL (required for overlay networks).
timeout: Connection timeout in seconds.
allow_insecure: If ``True``, fall back to insecure transport on SSL failure.
Returns:
Connected ``Client`` ready for use.
Raises:
TimeoutError: If connection times out.
ValueError: If overlay relay requested without ``proxy_url``.
ssl.SSLCertVerificationError: If SSL fails and ``allow_insecure`` is ``False``.
Note:
The clearnet fallback path requires calling ``uniffi_set_event_loop()``
before creating the
[InsecureWebSocketTransport][bigbrotr.utils.transport.InsecureWebSocketTransport],
because the custom transport uses UniFFI callbacks that need access
to the running asyncio event loop.
See Also:
[create_client][bigbrotr.utils.protocol.create_client]: Used for both
the initial SSL-verified attempt and the insecure fallback.
[is_nostr_relay][bigbrotr.utils.protocol.is_nostr_relay]: Higher-level
validation that uses this function internally.
"""
relay_url = RelayUrl.parse(relay.url)
is_overlay = relay.network in (NetworkType.TOR, NetworkType.I2P, NetworkType.LOKI)
if is_overlay:
if proxy_url is None:
raise ValueError(f"proxy_url required for {relay.network} relay: {relay.url}")
client = await create_client(keys, proxy_url)
await client.add_relay(relay_url)
await client.connect()
await client.wait_for_connection(timedelta(seconds=timeout))
relay_obj = await client.relay(relay_url)
if not relay_obj.is_connected():
await client.disconnect()
raise TimeoutError(f"Connection timeout: {relay.url}")
return client
# Clearnet: try SSL first, then fall back to insecure if allowed
logger.debug("ssl_connecting relay=%s", relay.url)
client = await create_client(keys)
await client.add_relay(relay_url)
output = await client.try_connect(timedelta(seconds=timeout))
if relay_url in output.success:
logger.debug("ssl_connected relay=%s", relay.url)
return client
await client.disconnect()
error_message = output.failed.get(relay_url, "Unknown error")
logger.debug("connect_failed relay=%s error=%s", relay.url, error_message)
if not _is_ssl_error(error_message):
raise OSError(f"Connection failed: {relay.url} ({error_message})")
if not allow_insecure:
raise ssl.SSLCertVerificationError(
f"SSL certificate verification failed for {relay.url}: {error_message}"
)
logger.debug("ssl_fallback_insecure relay=%s error=%s", relay.url, error_message)
# Required for custom WebSocket transport UniFFI callbacks
uniffi_set_event_loop(asyncio.get_running_loop())
client = await create_client(keys, allow_insecure=True)
await client.add_relay(relay_url)
output = await client.try_connect(timedelta(seconds=timeout))
if relay_url not in output.success:
error_message = output.failed.get(relay_url, "Unknown error")
await client.disconnect()
raise OSError(f"Connection failed (insecure): {relay.url} ({error_message})")
logger.debug("insecure_connected relay=%s", relay.url)
return client
|
broadcast_events
async
broadcast_events(
builders: list[EventBuilder],
relays: list[Relay],
keys: Keys,
*,
timeout: float = 30.0,
allow_insecure: bool = True,
) -> int
Sign and broadcast Nostr events to relays.
Creates a separate client per relay so that SSL fallback can be
applied independently. Relays that fail to connect or send are
logged at WARNING level and skipped.
Returns:
-
int
–
Number of relays that successfully received all events.
Source code in src/bigbrotr/utils/protocol.py
| async def broadcast_events(
builders: list[EventBuilder],
relays: list[Relay],
keys: Keys,
*,
timeout: float = 30.0, # noqa: ASYNC109
allow_insecure: bool = True,
) -> int:
"""Sign and broadcast Nostr events to relays.
Creates a separate client per relay so that SSL fallback can be
applied independently. Relays that fail to connect or send are
logged at WARNING level and skipped.
Returns:
Number of relays that successfully received all events.
"""
if not builders or not relays:
return 0
success = 0
for relay in relays:
try:
client = await connect_relay(
relay,
keys=keys,
timeout=timeout,
allow_insecure=allow_insecure,
)
except (OSError, TimeoutError) as e:
logger.warning("broadcast_connect_failed relay=%s error=%s", relay.url, e)
continue
try:
for builder in builders:
await client.send_event_builder(builder)
success += 1
except (OSError, TimeoutError) as e:
logger.warning("broadcast_send_failed relay=%s error=%s", relay.url, e)
finally:
with contextlib.suppress(Exception):
await client.shutdown()
return success
|
is_nostr_relay
async
is_nostr_relay(
relay: Relay,
proxy_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
*,
overall_timeout: float | None = None,
allow_insecure: bool = False,
) -> bool
Check if a URL hosts a Nostr relay by attempting a protocol handshake.
A relay is considered valid if it responds with EOSE to a REQ, sends
an AUTH challenge (NIP-42), or returns a CLOSED with "auth-required".
Parameters:
-
relay
(Relay)
–
-
proxy_url
(str | None, default:
None
)
–
SOCKS5 proxy URL (required for overlay networks).
-
timeout
(float, default:
DEFAULT_TIMEOUT
)
–
Timeout in seconds for connect and fetch operations.
-
overall_timeout
(float | None, default:
None
)
–
Total time budget for the entire validation
(connect + possible SSL fallback + fetch). Defaults to
timeout * 4 to cover: SSL attempt, disconnect, insecure
retry, and fetch.
-
allow_insecure
(bool, default:
False
)
–
If True, fall back to insecure transport on
SSL failure (passed through to
connect_relay).
Returns:
-
bool
–
True if the relay speaks the Nostr protocol, False otherwise.
See Also
connect_relay: Used
internally to establish the WebSocket connection.
bigbrotr.services.validator.Validator:
Service that calls this function to promote candidates to relays.
Source code in src/bigbrotr/utils/protocol.py
| async def is_nostr_relay(
relay: Relay,
proxy_url: str | None = None,
timeout: float = DEFAULT_TIMEOUT, # noqa: ASYNC109
*,
overall_timeout: float | None = None,
allow_insecure: bool = False,
) -> bool:
"""Check if a URL hosts a Nostr relay by attempting a protocol handshake.
A relay is considered valid if it responds with EOSE to a REQ, sends
an AUTH challenge (NIP-42), or returns a CLOSED with ``"auth-required"``.
Args:
relay: [Relay][bigbrotr.models.relay.Relay] to validate.
proxy_url: SOCKS5 proxy URL (required for overlay networks).
timeout: Timeout in seconds for connect and fetch operations.
overall_timeout: Total time budget for the entire validation
(connect + possible SSL fallback + fetch). Defaults to
``timeout * 4`` to cover: SSL attempt, disconnect, insecure
retry, and fetch.
allow_insecure: If ``True``, fall back to insecure transport on
SSL failure (passed through to
[connect_relay][bigbrotr.utils.protocol.connect_relay]).
Returns:
``True`` if the relay speaks the Nostr protocol, ``False`` otherwise.
See Also:
[connect_relay][bigbrotr.utils.protocol.connect_relay]: Used
internally to establish the WebSocket connection.
[bigbrotr.services.validator.Validator][bigbrotr.services.validator.Validator]:
Service that calls this function to promote candidates to relays.
"""
effective_overall = overall_timeout if overall_timeout is not None else timeout * 4
logger.debug("validation_started relay=%s timeout_s=%s", relay.url, timeout)
with _suppress_stderr():
client = None
try:
async with asyncio.timeout(effective_overall):
client = await connect_relay(
relay=relay,
proxy_url=proxy_url,
timeout=timeout,
allow_insecure=allow_insecure,
)
req_filter = Filter().kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(1)
await client.fetch_events(req_filter, timedelta(seconds=timeout))
logger.debug("validation_success relay=%s reason=%s", relay.url, "eose")
return True
except TimeoutError:
logger.debug("validation_timeout relay=%s", relay.url)
return False
except OSError as e:
# AUTH-required errors indicate a valid Nostr relay (NIP-42)
error_msg = str(e).lower()
if "auth-required" in error_msg:
logger.debug("validation_success relay=%s reason=%s", relay.url, "auth-required")
return True
logger.debug("validation_failed relay=%s error=%s", relay.url, str(e))
return False
finally:
if client is not None:
# nostr-sdk Rust FFI can raise arbitrary exception types during disconnect.
with contextlib.suppress(Exception):
await asyncio.wait_for(client.disconnect(), timeout=timeout)
|