326 lines
10 KiB
Python
326 lines
10 KiB
Python
"""FIPS node directory fetcher.
|
|
|
|
Ports the Nostr Kind 37195 / ``fips-overlay-v1`` advert query from
|
|
``sovereign_browser/src/nostr_bridge.c:handle_fips_directory_json`` to
|
|
Python using ``websocket-client``.
|
|
|
|
Each advert event's ``content`` is a JSON object matching
|
|
``fips/src/discovery/nostr/types.rs::OverlayAdvert``:
|
|
|
|
{
|
|
"identifier": "fips-overlay-v1",
|
|
"version": 1,
|
|
"endpoints": [{"transport": "udp", "addr": "host:port"}, ...],
|
|
"signalRelays": ["wss://...", ...]
|
|
}
|
|
|
|
Kind 37195 is parameterized-replaceable, so we keep the newest event per
|
|
pubkey (highest ``created_at``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Optional
|
|
|
|
import websocket # websocket-client
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Default FIPS advert relays (fips/src/config/node.rs::default_advert_relays).
|
|
DEFAULT_RELAYS = [
|
|
"wss://relay.damus.io",
|
|
"wss://nos.lol",
|
|
"wss://offchain.pub",
|
|
]
|
|
|
|
KIND_FIPS_ADVERT = 37195
|
|
ADVERT_IDENTIFIER = "fips-overlay-v1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bech32 (npub) encoding — no external dependency required.
|
|
# Implements BIP-173 / NIP-19 bech32 for the 32-byte x-only pubkey → npub case.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
|
|
|
|
def _bech32_polymod(values: list[int]) -> int:
|
|
gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
|
chk = 1
|
|
for v in values:
|
|
b = chk >> 25
|
|
chk = ((chk & 0x1FFFFFF) << 5) ^ v
|
|
for i in range(5):
|
|
chk ^= gen[i] if ((b >> i) & 1) else 0
|
|
return chk
|
|
|
|
|
|
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
|
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
|
|
|
|
|
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
|
values = _bech32_hrp_expand(hrp) + data
|
|
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
|
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
|
|
|
|
|
def _convertbits(data: bytes, frombits: int, tobits: int, pad: bool = True) -> list[int]:
|
|
acc = 0
|
|
bits = 0
|
|
ret: list[int] = []
|
|
maxv = (1 << tobits) - 1
|
|
max_acc = (1 << (frombits + tobits - 1)) - 1
|
|
for value in data:
|
|
if value < 0 or (value >> frombits):
|
|
raise ValueError("invalid value for convertbits")
|
|
acc = ((acc << frombits) | value) & max_acc
|
|
bits += frombits
|
|
while bits >= tobits:
|
|
bits -= tobits
|
|
ret.append((acc >> bits) & maxv)
|
|
if pad:
|
|
if bits:
|
|
ret.append((acc << (tobits - bits)) & maxv)
|
|
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
|
raise ValueError("invalid padding in convertbits")
|
|
return ret
|
|
|
|
|
|
def encode_npub(pubkey_bytes: bytes) -> str:
|
|
"""Encode a 32-byte x-only pubkey as an npub (bech32) string."""
|
|
if len(pubkey_bytes) != 32:
|
|
raise ValueError("npub requires 32 bytes")
|
|
hrp = "npub"
|
|
data = _convertbits(pubkey_bytes, 8, 5, pad=True)
|
|
combined = data + _bech32_create_checksum(hrp, data)
|
|
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined)
|
|
|
|
|
|
def hex_to_npub(pubkey_hex: str) -> str:
|
|
return encode_npub(bytes.fromhex(pubkey_hex))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Directory data model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Endpoint:
|
|
transport: str # "udp" | "tcp" | "tor"
|
|
addr: str # "host:port" or "nat"
|
|
|
|
|
|
@dataclass
|
|
class NodeEntry:
|
|
npub: str
|
|
pubkey_hex: str
|
|
created_at: int
|
|
endpoints: list[Endpoint] = field(default_factory=list)
|
|
signal_relays: list[str] = field(default_factory=list)
|
|
|
|
def first_dialable_endpoint(self) -> Optional[Endpoint]:
|
|
"""First endpoint with a concrete host:port (not 'nat')."""
|
|
for ep in self.endpoints:
|
|
if ep.addr.lower() != "nat" and ":" in ep.addr:
|
|
return ep
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Relay query
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_filter() -> dict[str, Any]:
|
|
return {
|
|
"kinds": [KIND_FIPS_ADVERT],
|
|
"#d": [ADVERT_IDENTIFIER],
|
|
"limit": 500,
|
|
}
|
|
|
|
|
|
def _query_one_relay(relay_url: str, timeout: float) -> list[dict]:
|
|
"""Open a WS to one relay, send REQ, collect EVENTs until EOSE, return events."""
|
|
events: list[dict] = []
|
|
subscription_id = f"meshscan-{int(time.time()*1000)}"
|
|
|
|
sock = None
|
|
try:
|
|
sock = websocket.create_connection(relay_url, timeout=timeout)
|
|
req = json.dumps(["REQ", subscription_id, _build_filter()])
|
|
sock.send(req)
|
|
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
sock.settimeout(max(0.5, deadline - time.time()))
|
|
try:
|
|
raw = sock.recv()
|
|
except websocket.WebSocketTimeoutException:
|
|
break
|
|
if not raw:
|
|
break
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(msg, list) or not msg:
|
|
continue
|
|
if msg[0] == "EOSE" and len(msg) > 1 and msg[1] == subscription_id:
|
|
break
|
|
if msg[0] == "EVENT" and len(msg) > 2:
|
|
events.append(msg[2])
|
|
except Exception as e:
|
|
log.warning("relay %s failed: %s", relay_url, e)
|
|
finally:
|
|
if sock is not None:
|
|
try:
|
|
sock.send(json.dumps(["CLOSE", subscription_id]))
|
|
sock.close()
|
|
except Exception:
|
|
pass
|
|
return events
|
|
|
|
|
|
def fetch_directory(
|
|
relays: list[str] | None = None,
|
|
timeout: float = 12.0,
|
|
) -> list[NodeEntry]:
|
|
"""Fetch the FIPS node directory from advert relays.
|
|
|
|
Queries all relays in parallel, dedups by pubkey keeping the newest
|
|
event (highest ``created_at``), and returns a list of
|
|
[`NodeEntry`](directory.py).
|
|
"""
|
|
relay_list = relays or DEFAULT_RELAYS
|
|
all_events: list[dict] = []
|
|
threads: list[threading.Thread] = []
|
|
results: list[list[dict]] = [[] for _ in relay_list]
|
|
|
|
def worker(i: int, url: str) -> None:
|
|
results[i] = _query_one_relay(url, timeout)
|
|
|
|
for i, url in enumerate(relay_list):
|
|
t = threading.Thread(target=worker, args=(i, url), daemon=True)
|
|
t.start()
|
|
threads.append(t)
|
|
for t in threads:
|
|
t.join(timeout + 2)
|
|
|
|
for r in results:
|
|
all_events.extend(r)
|
|
|
|
return _dedup_events(all_events)
|
|
|
|
|
|
def _dedup_events(events: list[dict]) -> list[NodeEntry]:
|
|
newest: dict[str, NodeEntry] = {}
|
|
for ev in events:
|
|
pk = ev.get("pubkey")
|
|
ca = ev.get("created_at", 0)
|
|
if not isinstance(pk, str):
|
|
continue
|
|
try:
|
|
npub = hex_to_npub(pk)
|
|
except ValueError:
|
|
continue
|
|
existing = newest.get(npub)
|
|
if existing is not None and ca <= existing.created_at:
|
|
continue
|
|
|
|
content = ev.get("content", "")
|
|
try:
|
|
advert = json.loads(content) if isinstance(content, str) else None
|
|
except json.JSONDecodeError:
|
|
advert = None
|
|
|
|
endpoints: list[Endpoint] = []
|
|
signal_relays: list[str] = []
|
|
if isinstance(advert, dict):
|
|
for ep in advert.get("endpoints", []) or []:
|
|
if isinstance(ep, dict) and "transport" in ep and "addr" in ep:
|
|
endpoints.append(Endpoint(str(ep["transport"]), str(ep["addr"])))
|
|
for r in advert.get("signalRelays", []) or []:
|
|
if isinstance(r, str):
|
|
signal_relays.append(r)
|
|
|
|
newest[npub] = NodeEntry(
|
|
npub=npub,
|
|
pubkey_hex=pk,
|
|
created_at=int(ca) if isinstance(ca, (int, float)) else 0,
|
|
endpoints=endpoints,
|
|
signal_relays=signal_relays,
|
|
)
|
|
|
|
return list(newest.values())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# npub → fd00:: IPv6 derivation (fips/src/identity/{node_addr,address}.rs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def npub_to_node_addr(npub: str) -> bytes:
|
|
"""Derive the 16-byte NodeAddr = first 16 bytes of SHA-256(pubkey)."""
|
|
# Decode bech32 npub → 32-byte pubkey.
|
|
hrp, data = _bech32_decode(npub)
|
|
if hrp != "npub":
|
|
raise ValueError(f"expected npub hrp, got {hrp}")
|
|
pubkey = _convertbits(data, 5, 8, pad=False)
|
|
if len(pubkey) != 32:
|
|
raise ValueError(f"decoded pubkey length {len(pubkey)} != 32")
|
|
return hashlib.sha256(bytes(pubkey)).digest()[:16]
|
|
|
|
|
|
def npub_to_ipv6(npub: str) -> str:
|
|
"""Derive the fd00::/8 IPv6 address for a node from its npub.
|
|
|
|
Mirrors ``FipsAddress::from_node_addr``: prepend 0xfd to the first 15
|
|
bytes of the NodeAddr.
|
|
"""
|
|
node_addr = npub_to_node_addr(npub)
|
|
addr_bytes = bytearray(16)
|
|
addr_bytes[0] = 0xFD
|
|
addr_bytes[1:16] = node_addr[0:15]
|
|
# Format as IPv6.
|
|
parts = [int.from_bytes(addr_bytes[i:i + 2], "big") for i in range(0, 16, 2)]
|
|
return ":".join(f"{p:x}" for p in parts)
|
|
|
|
|
|
def _bech32_decode(s: str) -> tuple[str, list[int]]:
|
|
"""Decode a bech32 string → (hrp, data5)."""
|
|
if any(ord(x) < 33 or ord(x) > 126 for x in s):
|
|
raise ValueError("invalid bech32 character")
|
|
pos = s.rfind("1")
|
|
if pos < 1 or pos + 7 > len(s):
|
|
raise ValueError("invalid bech32 structure")
|
|
hrp = s[:pos]
|
|
data = [_BECH32_CHARSET.index(x) for x in s[pos + 1:].lower()]
|
|
# Verify checksum.
|
|
if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1:
|
|
raise ValueError("invalid bech32 checksum")
|
|
return hrp, data[:-6]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
p = argparse.ArgumentParser(description="Fetch the FIPS node directory")
|
|
p.add_argument("--relay", action="append", help="relay URL (repeatable)")
|
|
p.add_argument("--timeout", type=float, default=12.0)
|
|
args = p.parse_args()
|
|
nodes = fetch_directory(args.relay, args.timeout)
|
|
print(f"# {len(nodes)} nodes")
|
|
for n in nodes:
|
|
ep = n.first_dialable_endpoint()
|
|
ep_str = f"{ep.transport}:{ep.addr}" if ep else "none"
|
|
print(f"{n.npub} created_at={n.created_at} ep={ep_str}")
|