Add NIP-42 authentication to relay discovery (#10)

* Add NIP-42 authentication to relay discovery

* Move discovery tests into tests directory
This commit is contained in:
a1denvalu3
2026-07-23 19:45:57 +02:00
committed by GitHub
parent cf18649ee9
commit 367979ea3d
4 changed files with 264 additions and 10 deletions
+5
View File
@@ -14,6 +14,9 @@ The repository contains three scripts that form a simple pipeline:
- Starts from a seed relay URL and connects to it via WebSocket (ws/wss). - Starts from a seed relay URL and connects to it via WebSocket (ws/wss).
- Breadthfirst searches follow lists (kind 3) and relay lists (kind 10002), extracting relay URLs from event tags (r/p tags). - Breadthfirst searches follow lists (kind 3) and relay lists (kind 10002), extracting relay URLs from event tags (r/p tags).
- Tests each candidate relay for basic Nostr protocol responsiveness by issuing a REQ and evaluating responses (EVENT/EOSE/NOTICE). - Tests each candidate relay for basic Nostr protocol responsiveness by issuing a REQ and evaluating responses (EVENT/EOSE/NOTICE).
- Responds to NIP-42 authentication challenges. An ephemeral Nostr key is
generated by default; set `NOSTR_PRIVATE_KEY` or pass `--private-key` with a
64-character hex secret when testing membership-restricted relays.
- Processes relays in concurrent batches and periodically saves progress to `relay_discovery_results.json`. - Processes relays in concurrent batches and periodically saves progress to `relay_discovery_results.json`.
- Output: `relay_discovery_results.json` with: - Output: `relay_discovery_results.json` with:
- `functioning_relays` (array of ws/wss relay URLs) - `functioning_relays` (array of ws/wss relay URLs)
@@ -42,6 +45,8 @@ Note: The geolocation step is IPv4only (the DBIP file used here is IPv4).
## Tips and caveats ## Tips and caveats
- The discovery step uses timeouts and concurrent batches; tune `--batch-size`, `--timeout`, and `--max-depth` for your environment. - The discovery step uses timeouts and concurrent batches; tune `--batch-size`, `--timeout`, and `--max-depth` for your environment.
- NIP-42 authentication proves control of a key but does not bypass relay
membership or payment policies. Use a relay-authorized key when required.
- The BitChat filter posts a test kind 20000 event. Ensure `nak` is configured to publish (e.g., via its config or environment variables as per `nak` docs). - The BitChat filter posts a test kind 20000 event. Ensure `nak` is configured to publish (e.g., via its config or environment variables as per `nak` docs).
- Geolocation is an estimate based on DBIP and only for IPv4. Accuracy varies and may reflect the ISP/hosting POP rather than precise server location. - Geolocation is an estimate based on DBIP and only for IPv4. Accuracy varies and may reflect the ISP/hosting POP rather than precise server location.
- `relays_geo_lookup.py` stops at the first IPv4 for which DBIP provides coordinates. Some hostnames resolve to multiple IPs. - `relays_geo_lookup.py` stops at the first IPv4 for which DBIP provides coordinates. Some hostnames resolve to multiple IPs.
+137 -10
View File
@@ -7,8 +7,10 @@ follow lists (kind 3 events) and analyzing relay tags to build a network map.
""" """
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import os
import re import re
import time import time
import argparse import argparse
@@ -20,6 +22,7 @@ from typing import Set, List, Dict, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
import websockets import websockets
import websockets.exceptions import websockets.exceptions
from coincurve import PrivateKey
# Configure logging # Configure logging
@@ -60,13 +63,14 @@ class RelayDiscoveryStats:
class NostrRelayDiscovery: class NostrRelayDiscovery:
"""Nostr relay discovery tool using breadth-first search through follow lists""" """Nostr relay discovery tool using breadth-first search through follow lists"""
def __init__(self, initial_relay: str, max_depth: int = 3, connection_timeout: int = 5, output_file: str = "relay_discovery_results.json", save_point: int = SAVE_POINT, batch_size: int = 10): def __init__(self, initial_relay: str, max_depth: int = 3, connection_timeout: int = 5, output_file: str = "relay_discovery_results.json", save_point: int = SAVE_POINT, batch_size: int = 10, private_key: Optional[str] = None):
self.initial_relay = initial_relay self.initial_relay = initial_relay
self.max_depth = max_depth self.max_depth = max_depth
self.connection_timeout = connection_timeout self.connection_timeout = connection_timeout
self.output_file = output_file self.output_file = output_file
self.save_point = save_point self.save_point = save_point
self.batch_size = batch_size self.batch_size = batch_size
self.private_key = self._load_private_key(private_key)
# Discovery state # Discovery state
self.to_visit: deque = deque() # (relay_url, depth) - will be populated by load_existing_results self.to_visit: deque = deque() # (relay_url, depth) - will be populated by load_existing_results
@@ -121,6 +125,119 @@ class NostrRelayDiscovery:
"""Generate a random subscription ID using 16 random bytes encoded as base64""" """Generate a random subscription ID using 16 random bytes encoded as base64"""
random_bytes = secrets.token_bytes(16) random_bytes = secrets.token_bytes(16)
return base64.b64encode(random_bytes).decode() return base64.b64encode(random_bytes).decode()
@staticmethod
def _load_private_key(private_key_hex: Optional[str]) -> PrivateKey:
"""Load a configured Nostr secret or generate an ephemeral discovery key."""
if private_key_hex is None:
return PrivateKey()
try:
secret = bytes.fromhex(private_key_hex)
except ValueError as exc:
raise ValueError("--private-key must be 64 hexadecimal characters") from exc
if len(secret) != 32:
raise ValueError("--private-key must be 64 hexadecimal characters")
try:
return PrivateKey(secret)
except ValueError as exc:
raise ValueError("--private-key is not a valid secp256k1 secret") from exc
def build_auth_event(self, relay_url: str, challenge: str) -> Dict:
"""Build and sign a NIP-42 kind 22242 authentication event."""
pubkey = self.private_key.public_key.format(compressed=True)[1:].hex()
event = {
"pubkey": pubkey,
"created_at": int(time.time()),
"kind": 22242,
"tags": [
["relay", relay_url],
["challenge", challenge],
],
"content": "",
}
serialized = json.dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
],
separators=(",", ":"),
ensure_ascii=False,
).encode()
event_id = hashlib.sha256(serialized).digest()
event["id"] = event_id.hex()
event["sig"] = self.private_key.sign_schnorr(event_id).hex()
return event
async def receive_with_auth(
self, websocket, relay_url: str, timeout: float, request_message=None
):
"""Receive one application message, completing NIP-42 if challenged.
Some relays reject the request that triggered authentication before
processing the AUTH response. When ``request_message`` is supplied, it
is sent again after authentication succeeds.
"""
deadline = asyncio.get_running_loop().time() + timeout
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise asyncio.TimeoutError
response = await asyncio.wait_for(websocket.recv(), timeout=remaining)
data = json.loads(response)
if not isinstance(data, list) or not data:
return data
if data[0] != "AUTH":
return data
if len(data) < 2 or not isinstance(data[1], str):
raise ValueError("relay sent a malformed NIP-42 AUTH challenge")
auth_event = self.build_auth_event(relay_url, data[1])
await websocket.send(json.dumps(["AUTH", auth_event]))
logger.debug(f"Sent NIP-42 AUTH response to {relay_url}")
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise asyncio.TimeoutError
while True:
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
raise asyncio.TimeoutError
auth_response = json.loads(
await asyncio.wait_for(websocket.recv(), timeout=remaining)
)
if (
isinstance(auth_response, list)
and len(auth_response) >= 3
and auth_response[0] == "OK"
and auth_response[1] == auth_event["id"]
):
if auth_response[2] is not True:
reason = (
auth_response[3]
if len(auth_response) > 3
else "authentication rejected"
)
raise PermissionError(
f"NIP-42 authentication failed: {reason}"
)
break
logger.debug(f"NIP-42 authentication succeeded with {relay_url}")
if request_message is not None:
await websocket.send(json.dumps(request_message))
logger.debug(f"Resent request after NIP-42 authentication: {relay_url}")
async def test_relay_connection(self, relay_url: str) -> bool: async def test_relay_connection(self, relay_url: str) -> bool:
"""Test if a relay is functioning by attempting to connect and validate Nostr protocol responses""" """Test if a relay is functioning by attempting to connect and validate Nostr protocol responses"""
@@ -146,12 +263,12 @@ class NostrRelayDiscovery:
# Wait for a response and validate it's a proper Nostr protocol message # Wait for a response and validate it's a proper Nostr protocol message
try: try:
response = await asyncio.wait_for(websocket.recv(), timeout=5.0) data = await self.receive_with_auth(
logger.debug(f"Received response: {response[:200]}...") # Log first 200 chars websocket, relay_url, 5.0, req_msg
)
logger.debug(f"Received response: {str(data)[:200]}...")
try: try:
data = json.loads(response)
# Check if it's a valid Nostr protocol message # Check if it's a valid Nostr protocol message
if not isinstance(data, list) or len(data) < 2: if not isinstance(data, list) or len(data) < 2:
logger.debug(f"Invalid Nostr message format from {relay_url}: not a list or too short") logger.debug(f"Invalid Nostr message format from {relay_url}: not a list or too short")
@@ -186,7 +303,7 @@ class NostrRelayDiscovery:
logger.debug(f"Unexpected message type from {relay_url}: {message_type}") logger.debug(f"Unexpected message type from {relay_url}: {message_type}")
return False return False
except json.JSONDecodeError as e: except (json.JSONDecodeError, ValueError) as e:
logger.debug(f"Invalid JSON response from {relay_url}: {e}") logger.debug(f"Invalid JSON response from {relay_url}: {e}")
return False return False
@@ -261,8 +378,9 @@ class NostrRelayDiscovery:
while time.time() - start_time < timeout_duration: while time.time() - start_time < timeout_duration:
try: try:
message = await asyncio.wait_for(websocket.recv(), timeout=5.0) data = await self.receive_with_auth(
data = json.loads(message) websocket, relay_url, 5.0, req_msg
)
if data[0] == "EVENT" and data[1] == subscription_id: if data[0] == "EVENT" and data[1] == subscription_id:
event = data[2] event = data[2]
@@ -543,6 +661,14 @@ async def main():
default=10, default=10,
help="Number of relays to process concurrently (default: 10)" help="Number of relays to process concurrently (default: 10)"
) )
parser.add_argument(
"--private-key",
default=os.environ.get("NOSTR_PRIVATE_KEY"),
help=(
"64-character hex Nostr private key for NIP-42 authentication "
"(default: NOSTR_PRIVATE_KEY or an ephemeral key)"
),
)
parser.add_argument( parser.add_argument(
"--verbose", "--verbose",
action="store_true", action="store_true",
@@ -559,9 +685,10 @@ async def main():
args.initial_relay, args.initial_relay,
args.max_depth, args.max_depth,
args.timeout, args.timeout,
args.output, args.output,
args.save_point, args.save_point,
args.batch_size args.batch_size,
args.private_key,
) )
if not discovery.is_valid_relay_url(args.initial_relay): if not discovery.is_valid_relay_url(args.initial_relay):
print(f"Error: Invalid relay URL: {args.initial_relay}") print(f"Error: Invalid relay URL: {args.initial_relay}")
+1
View File
@@ -1,4 +1,5 @@
websockets>=12.0 websockets>=12.0
coincurve>=20.0.0
matplotlib>=3.5.0 matplotlib>=3.5.0
pandas>=1.4.0 pandas>=1.4.0
numpy>=1.20.0 numpy>=1.20.0
+121
View File
@@ -0,0 +1,121 @@
import asyncio
import hashlib
import json
import unittest
from unittest.mock import patch
from coincurve import PublicKeyXOnly
from nostr_relay_discovery import NostrRelayDiscovery
class FakeWebSocket:
def __init__(self, messages):
self.messages = iter(messages)
self.sent = []
async def recv(self):
return next(self.messages)
async def send(self, message):
self.sent.append(json.loads(message))
class Nip42Tests(unittest.TestCase):
def setUp(self):
self.discovery = NostrRelayDiscovery(
"wss://relay.example.com",
private_key="01".zfill(64),
)
def test_build_auth_event_is_valid_schnorr_event(self):
with patch("nostr_relay_discovery.time.time", return_value=1_700_000_000):
event = self.discovery.build_auth_event(
"wss://relay.example.com", "challenge"
)
serialized = json.dumps(
[
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
],
separators=(",", ":"),
ensure_ascii=False,
).encode()
event_id = hashlib.sha256(serialized).digest()
self.assertEqual(event["id"], event_id.hex())
self.assertTrue(
PublicKeyXOnly(bytes.fromhex(event["pubkey"])).verify(
bytes.fromhex(event["sig"]), event_id
)
)
def test_receive_with_auth_returns_subscription_response(self):
async def run_test():
auth_event = self.discovery.build_auth_event(
"wss://relay.example.com", "challenge"
)
websocket = FakeWebSocket(
[
json.dumps(["AUTH", "challenge"]),
json.dumps(
["CLOSED", "subscription", "auth-required: authenticate"]
),
json.dumps(["OK", auth_event["id"], True, ""]),
json.dumps(["EOSE", "subscription"]),
]
)
with patch.object(
self.discovery, "build_auth_event", return_value=auth_event
):
response = await self.discovery.receive_with_auth(
websocket,
"wss://relay.example.com",
5,
["REQ", "subscription", {"kinds": [1]}],
)
self.assertEqual(response, ["EOSE", "subscription"])
self.assertEqual(
websocket.sent,
[
["AUTH", auth_event],
["REQ", "subscription", {"kinds": [1]}],
],
)
asyncio.run(run_test())
def test_receive_with_auth_reports_rejection(self):
async def run_test():
auth_event = self.discovery.build_auth_event(
"wss://relay.example.com", "challenge"
)
websocket = FakeWebSocket(
[
json.dumps(["AUTH", "challenge"]),
json.dumps(
["OK", auth_event["id"], False, "restricted: not a member"]
),
]
)
with patch.object(
self.discovery, "build_auth_event", return_value=auth_event
):
with self.assertRaisesRegex(PermissionError, "not a member"):
await self.discovery.receive_with_auth(
websocket, "wss://relay.example.com", 5
)
asyncio.run(run_test())
if __name__ == "__main__":
unittest.main()