Files
2026-07-20 12:10:52 -04:00

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}")