diff --git a/pairing.py b/pairing.py index 83933e8..c2d923f 100644 --- a/pairing.py +++ b/pairing.py @@ -23,21 +23,15 @@ We deliberately do NOT run the connect/eager-bind step here: the spire is the NIP-46 client, so the binding must happen spire-side with the spire's own client keypair. spirekeeper only mints + packages. -Seed URL wire format (contract shared with bitspire#52, slimmed in bitspire#70): +Seed URL wire format (contract shared with bitspire#52): spire-seed:v1: json = { "v": 1, - "spire_npub": "npub1…", # the bunker-minted spire identity - "lnbits_npub": "npub1…", # this lnbits' nostr-transport server id - "bunker_secret": "", # one-shot NIP-46 connect token - "relays": ["wss://…"], # relays for the spire's own events - "bunker_relay": "wss://…", # OPTIONAL — omitted when == relays[0] + "spire_npub": "npub1…", # the bunker-minted spire identity + "spire_pubkey": "<64-hex>", # same key, hex (consumer convenience) + "bunker_url": "bunker://?relay=&secret=", + "relays": ["wss://…"], # relays for the spire's own events } - -The pubkey is carried ONCE, as an npub: the consumer derives spire_pubkey (hex) -and reconstructs the bunker:// URL from spire_npub + bunker_relay|relays[0] + -bunker_secret. `lnbits_npub` lets a paired machine reach this lnbits' transport -with nothing else provisioned. See bitspire packages/nostr-client/src/seed.ts. """ from __future__ import annotations @@ -54,7 +48,6 @@ from lnbits.core.services.nsec_bunker import ( ) from lnbits.core.signers.remote_bunker import ensure_policy from lnbits.settings import settings -from lnbits.utils.nostr import hex_to_npub from pydantic import BaseModel from .models import Machine @@ -143,27 +136,15 @@ def _recover_token(tokens: list[dict], client_name: str) -> str: def build_seed_url( - *, - spire_npub: str, - lnbits_npub: str, - bunker_secret: str, - relays: list[str], - bunker_relay: str | None = None, + *, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] ) -> str: - """Build the slim seed URL (bitspire#70). The pubkey rides once as - `spire_npub`; the consumer derives the hex + reconstructs `bunker_url` from - `bunker_secret` + `bunker_relay` (or `relays[0]`). `bunker_relay` is emitted - only when it differs from `relays[0]`, keeping the common case one field - lighter.""" - payload: dict = { + payload = { "v": 1, "spire_npub": spire_npub, - "lnbits_npub": lnbits_npub, - "bunker_secret": bunker_secret, + "spire_pubkey": spire_pubkey_hex, + "bunker_url": bunker_url, "relays": relays, } - if bunker_relay and relays and bunker_relay != relays[0]: - payload["bunker_relay"] = bunker_relay blob = ( base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) .decode() @@ -252,31 +233,15 @@ async def pair_spire( token = _recover_token(tokens, client_name) _, _, secret = token.partition("#") - # The spire needs THIS lnbits' nostr-transport server identity to reach the - # backend from the seed alone (bitspire#70). The transport sets - # `settings.nostr_transport_public_key` at startup; if it's empty the - # transport isn't running, so we can't mint a self-sufficient seed. - lnbits_pubkey_hex = settings.nostr_transport_public_key - if not lnbits_pubkey_hex: - raise PairingError( - "LNbits nostr transport has no server pubkey " - "(settings.nostr_transport_public_key is empty) — is the transport " - "running? Cannot mint a self-sufficient seed." - ) - lnbits_npub = hex_to_npub(lnbits_pubkey_hex) - - # bunker_url is still returned in PairResult (operator display / audit); the - # seed itself no longer embeds it — the consumer reconstructs it. bunker_url = ( f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}" f"&secret={quote(secret, safe='')}" ) seed_url = build_seed_url( spire_npub=spire_npub, - lnbits_npub=lnbits_npub, - bunker_secret=secret, + spire_pubkey_hex=spire_pubkey_hex, + bunker_url=bunker_url, relays=relays, - bunker_relay=relay, ) return PairResult( spire_npub=spire_npub, diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index 745bbfe..0d50d95 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -66,9 +66,7 @@ def _wire(monkeypatch, *, pair="ok"): async def fake_owned(machine_id, user_id): return _machine() - async def fake_pair( - machine, *, relays, admin_client, bunker_relay=None, duration_hours=None - ): + async def fake_pair(machine, *, relays, admin_client, duration_hours=None): if pair == "error": raise PairingError("boom") return _result() @@ -82,18 +80,11 @@ def _wire(monkeypatch, *, pair="ok"): state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name) return _machine(npub=machine_npub) - # After pairing, the endpoint reads super_config to publish fee config - # (soft-fail tail). None short-circuits it — the happy-path assertions - # don't exercise fee publishing, and it keeps the test off the DB. - async def fake_super_config(): - return None - monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned) monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) monkeypatch.setattr(views_api, "pair_spire", fake_pair) monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision) monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist) - monkeypatch.setattr(views_api, "get_super_config", fake_super_config) return state diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 1062afd..e3d2999 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -17,7 +17,6 @@ from datetime import datetime, timezone import pytest from lnbits.core.services.nsec_bunker import NsecBunkerError -from lnbits.settings import settings from lnbits.utils.nostr import hex_to_npub from ..models import Machine @@ -36,23 +35,11 @@ from ..pairing import ( _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" _SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) -_LNBITS_HEX = "b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf" -_LNBITS_NPUB = hex_to_npub(_LNBITS_HEX) _RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"] _BUNKER_RELAY = "wss://bunker.internal/relay" _PASSPHRASE = "keystore-pass" # pragma: allowlist secret -@pytest.fixture(autouse=True) -def _set_transport_pubkey(): - # pair_spire reads this lnbits' nostr-transport server pubkey to embed - # lnbits_npub in the seed (bitspire#70). Set it for every test; restore after. - prev = settings.nostr_transport_public_key - settings.nostr_transport_public_key = _LNBITS_HEX - yield - settings.nostr_transport_public_key = prev - - @pytest.fixture(autouse=True) def _clear_policy_cache(): # lnbits' ensure_policy caches resolved policy ids on @@ -170,28 +157,18 @@ def test_bunker_url_carries_pubkey_relay_secret(): assert "secret=topsecret" in result.bunker_url -def _decode_seed(seed_url: str) -> dict: - assert seed_url.startswith(SEED_URL_SCHEME) - blob = seed_url[len(SEED_URL_SCHEME) :] - return json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) - - def test_seed_url_decodes_to_contract(): - # _pair passes an explicit bunker_relay distinct from relays[0], so it's - # carried; the pubkey rides once as spire_npub, lnbits_npub is embedded, - # and neither spire_pubkey nor bunker_url appears (bitspire#70). result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret - payload = _decode_seed(result.seed_url) + assert result.seed_url.startswith(SEED_URL_SCHEME) + blob = result.seed_url[len(SEED_URL_SCHEME) :] + payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) assert payload == { "v": 1, "spire_npub": _SPIRE_NPUB, - "lnbits_npub": _LNBITS_NPUB, - "bunker_secret": "zzz", + "spire_pubkey": _SPIRE_HEX, + "bunker_url": result.bunker_url, "relays": _RELAYS, - "bunker_relay": _BUNKER_RELAY, } - assert "spire_pubkey" not in payload - assert "bunker_url" not in payload def test_fresh_policy_adds_kindless_nip44_rules(): @@ -289,40 +266,14 @@ def test_missing_relay_or_passphrase_raises(): def test_build_seed_url_roundtrip(): url = build_seed_url( spire_npub=_SPIRE_NPUB, - lnbits_npub=_LNBITS_NPUB, - bunker_secret="s", # pragma: allowlist secret + spire_pubkey_hex=_SPIRE_HEX, + bunker_url="bunker://x?relay=r&secret=s", relays=_RELAYS, - bunker_relay=_BUNKER_RELAY, ) - payload = _decode_seed(url) - assert payload["spire_npub"] == _SPIRE_NPUB - assert payload["lnbits_npub"] == _LNBITS_NPUB - assert payload["bunker_secret"] == "s" + blob = url[len(SEED_URL_SCHEME) :] + payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) + assert payload["spire_pubkey"] == _SPIRE_HEX assert payload["relays"] == _RELAYS - assert payload["bunker_relay"] == _BUNKER_RELAY - - -def test_build_seed_url_omits_default_bunker_relay(): - # bunker_relay omitted when it equals relays[0] (or is None) — the consumer - # defaults it to relays[0], so carrying it would be redundant bytes. - for same in (_RELAYS[0], None): - payload = _decode_seed( - build_seed_url( - spire_npub=_SPIRE_NPUB, - lnbits_npub=_LNBITS_NPUB, - bunker_secret="s", # pragma: allowlist secret - relays=_RELAYS, - bunker_relay=same, - ) - ) - assert "bunker_relay" not in payload - - -def test_pair_missing_transport_pubkey_raises(): - # No transport server pubkey → can't mint a self-sufficient seed. - settings.nostr_transport_public_key = "" - with pytest.raises(PairingError, match="transport"): - _pair(FakeBunker()) def test_pair_threads_duration_hours():