mirror of
https://github.com/permissionlesstech/georelays.git
synced 2026-07-24 21:45:19 +00:00
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:
@@ -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).
|
||||||
- Breadth‑first searches follow lists (kind 3) and relay lists (kind 10002), extracting relay URLs from event tags (r/p tags).
|
- Breadth‑first 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 IPv4‑only (the DB‑IP 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 DB‑IP and only for IPv4. Accuracy varies and may reflect the ISP/hosting POP rather than precise server location.
|
- Geolocation is an estimate based on DB‑IP 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 DB‑IP provides coordinates. Some hostnames resolve to multiple IPs.
|
- `relays_geo_lookup.py` stops at the first IPv4 for which DB‑IP provides coordinates. Some hostnames resolve to multiple IPs.
|
||||||
|
|||||||
+137
-10
@@ -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,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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user