Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main (push) Failing after 0s

Reviewed-on: #37
This commit is contained in:
padreug 2026-07-02 17:45:01 +00:00
commit 8b64e9c742
7 changed files with 474 additions and 67 deletions

View file

@ -94,10 +94,12 @@ class PairMachineData(BaseModel):
the relay lnbits uses to reach the bunker differs from the one the spire the relay lnbits uses to reach the bunker differs from the one the spire
must reach e.g. an internal docker hostname (`ws://lnbits:5001/`) vs a must reach e.g. an internal docker hostname (`ws://lnbits:5001/`) vs a
LAN/public URL (`ws://192.168.0.32:5001/`), or any split-relay deploy. LAN/public URL (`ws://192.168.0.32:5001/`), or any split-relay deploy.
`duration_hours` optionally time-bounds the spire's connect token `relays` is optional: when omitted it defaults to the relay the transport
(None = non-expiring).""" listens on (derived from the transport config), so the operator needn't
supply one (bitspire#70). `duration_hours` optionally time-bounds the
spire's connect token (None = non-expiring)."""
relays: list[str] relays: list[str] | None = None
bunker_relay: str | None = None bunker_relay: str | None = None
duration_hours: int | None = None duration_hours: int | None = None

View file

@ -23,31 +23,41 @@ 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 NIP-46 client, so the binding must happen spire-side with the spire's own
client keypair. spirekeeper only mints + packages. client keypair. spirekeeper only mints + packages.
Seed URL wire format (contract shared with bitspire#52): Seed URL wire format (contract shared with bitspire#52, slimmed in bitspire#70):
spire-seed:v1:<base64url(json)> json = { spire-seed:v1:<base64url(json)> json = {
"v": 1, "v": 1,
"spire_npub": "npub1…", # the bunker-minted spire identity "spire_npub": "npub1…", # the bunker-minted spire identity
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience) "lnbits_npub": "npub1…", # this lnbits' nostr-transport server id
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>", "bunker_secret": "<sec>", # one-shot NIP-46 connect token
"relays": ["wss://…"], # relays for the spire's own events "relays": ["wss://…"], # relays for the spire's own events
"bunker_relay": "wss://…", # OPTIONAL — omitted when == relays[0]
} }
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 from __future__ import annotations
import asyncio
import base64 import base64
import json import json
from urllib.parse import quote import re
from urllib.parse import quote, urlparse
from lnbits.core.services.nsec_bunker import ( from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient, NsecBunkerAdminClient,
NsecBunkerError, NsecBunkerError,
NsecBunkerNotConfiguredError, NsecBunkerNotConfiguredError,
NsecBunkerTimeoutError,
npub_to_hex, npub_to_hex,
) )
from lnbits.core.signers.remote_bunker import ensure_policy from lnbits.core.signers.remote_bunker import ensure_policy
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.nostr import hex_to_npub
from pydantic import BaseModel from pydantic import BaseModel
from .models import Machine from .models import Machine
@ -135,16 +145,51 @@ def _recover_token(tokens: list[dict], client_name: str) -> str:
return token return token
async def _bunker_retry(call, *, attempts: int = 5, delay: float = 0.4, retry_empty: bool = False):
"""Retry a bunker admin RPC past a TRANSIENT failure — a `NsecBunkerTimeoutError`
(the nsecbunkerd briefly slow / unresponsive) and, when `retry_empty`, an empty
result (a just-issued token not yet listed). Both are seen intermittently on a
healthy bunker (bitspire#70 / spirekeeper#38). A real rejection
(`NsecBunkerRpcError`) or misconfig (`NsecBunkerNotConfiguredError`) is terminal
and propagates immediately. `call` is a zero-arg coroutine factory."""
for attempt in range(attempts):
last = attempt == attempts - 1
try:
result = await call()
except NsecBunkerTimeoutError:
if last:
raise
await asyncio.sleep(delay)
continue
if retry_empty and not result and not last:
await asyncio.sleep(delay)
continue
return result
raise AssertionError("unreachable: the final attempt returns or raises")
def build_seed_url( def build_seed_url(
*, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] *,
spire_npub: str,
lnbits_npub: str,
bunker_secret: str,
relays: list[str],
bunker_relay: str | None = None,
) -> str: ) -> str:
payload = { """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 = {
"v": 1, "v": 1,
"spire_npub": spire_npub, "spire_npub": spire_npub,
"spire_pubkey": spire_pubkey_hex, "lnbits_npub": lnbits_npub,
"bunker_url": bunker_url, "bunker_secret": bunker_secret,
"relays": relays, "relays": relays,
} }
if bunker_relay and relays and bunker_relay != relays[0]:
payload["bunker_relay"] = bunker_relay
blob = ( blob = (
base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
.decode() .decode()
@ -153,10 +198,67 @@ def build_seed_url(
return SEED_URL_SCHEME + blob return SEED_URL_SCHEME + blob
_WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE)
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
def _baseurl_ws() -> str | None:
"""`settings.lnbits_baseurl` as a ws(s):// scheme+host (no trailing slash)."""
base = (settings.lnbits_baseurl or "").strip().rstrip("/")
if base.startswith("https://"):
return "wss://" + base[len("https://") :]
if base.startswith("http://"):
return "ws://" + base[len("http://") :]
return None
def default_relay_endpoint() -> str | None:
"""Derive the machine-facing relay from lnbits' nostr-transport config, so a
seed points at the relay the transport ACTUALLY listens on (bitspire#70).
NOTE: this is the nostr *relay* (a full relay that acks EVENTs), NOT the
nostrclient proxy the client multiplexer forwards subscriptions but never
sends `OK`, so a transport client that awaits `OK` on publish times out.
If the transport relay is already machine-reachable, use it as-is; if it's a
co-located loopback relay (the bundled nostrrelay at
`ws://localhost:5001/nostrrelay/<id>`), re-home its path on lnbits' public
base URL so a remote ATM can reach the same relay. Returns None if neither
a transport relay nor a base URL is available."""
relays = settings.nostr_transport_relays or []
if not relays:
return None
relay = relays[0]
host = (urlparse(relay).hostname or "").lower()
if host and host not in _LOCAL_HOSTS and not host.endswith(".localhost"):
return relay # already remote-reachable
base = _baseurl_ws()
if not base:
return None
return base + urlparse(relay).path
def _validate_relay(url: str, field: str) -> None:
"""A seed relay must be a `ws(s)://` URL reachable by a REMOTE machine. Reject
loopback hosts: a localhost relay in a seed is the exact unreachable case
that silently crash-loops pairing (bitspire#70)."""
if not isinstance(url, str) or not _WS_SCHEME_RE.match(url):
raise PairingError(f"{field} must be a ws:// or wss:// URL (got {url!r})")
host = (urlparse(url).hostname or "").lower()
if not host:
raise PairingError(f"{field} has no host ({url!r})")
if host in _LOCAL_HOSTS or host.endswith(".localhost"):
raise PairingError(
f"{field} points at localhost ({url!r}). A pairing seed is redeemed by "
"a REMOTE machine — set lnbits_baseurl (and any explicit relay) to a "
"machine-reachable address."
)
async def pair_spire( async def pair_spire(
machine: Machine, machine: Machine,
*, *,
relays: list[str], relays: list[str] | None = None,
admin_client: NsecBunkerAdminClient, admin_client: NsecBunkerAdminClient,
bunker_relay: str | None = None, bunker_relay: str | None = None,
keystore_passphrase: str | None = None, keystore_passphrase: str | None = None,
@ -180,10 +282,14 @@ async def pair_spire(
with a fake client. with a fake client.
`relays` are the relays the spire uses for its *own* events `relays` are the relays the spire uses for its *own* events
(kind-21000/30078) typically the operator's public nostrrelay; supplied by (kind-21000/30078). When omitted, they default to the relay the transport
the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the listens on (`default_relay_endpoint`, from `nostr_transport_relays` re-homed
spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` on `lnbits_baseurl`) so a seed points at a relay that actually acks EVENTs.
defaults to the lnbits bunker setting. Both injectable for tests. Every relay (and `bunker_relay`) is validated as a remote-reachable
`ws(s)://` URL a loopback host is rejected (bitspire#70).
`bunker_relay` (the relay baked into `bunker_url`, where the spire reaches
the bunker) defaults to `relays[0]`; `keystore_passphrase` defaults to the
lnbits bunker setting. All injectable for tests.
Raises PairingError on any bunker failure; no state is persisted here Raises PairingError on any bunker failure; no state is persisted here
(the API layer persists on success). (the API layer persists on success).
@ -198,33 +304,53 @@ async def pair_spire(
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
"cannot mint a spire key" "cannot mint a spire key"
) )
# Default the spire's event relay(s) to the transport's own relay (re-homed
# on the base URL) when the caller gives none.
if not relays: if not relays:
raise PairingError("at least one relay is required for the seed URL") endpoint = default_relay_endpoint()
# The relay baked into `bunker_url` is where the *spire* (the remote ATM) if not endpoint:
# reaches the bunker, so it must be a machine-reachable public URL — NOT raise PairingError(
# `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits "no relays given and none could be derived — set "
# reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM — "nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)"
# the localhost-relay /pair gotcha bitspire flagged). Default to the spire's )
# own event relay (the bunker lives on the same operator relay the spire relays = [endpoint]
# publishes to); an explicit `bunker_relay` overrides for split-relay deploys.
# Validate every relay is a remote-reachable ws(s):// URL (rejects loopback +
# the ws://→As:// class of corruption). The bunker relay is validated too:
# it's baked into `bunker_url` where the *spire* (remote ATM) reaches the
# bunker, so it must be machine-reachable — NOT `settings.lnbits_nsec_bunker_url`
# (the co-located ws://127.0.0.1 path). Default it to the spire's own event
# relay; an explicit `bunker_relay` overrides for split-relay deploys.
for i, r in enumerate(relays):
_validate_relay(r, f"relays[{i}]")
if bunker_relay:
_validate_relay(bunker_relay, "bunker_relay")
relay = bunker_relay if bunker_relay else relays[0] relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id) key_name = spire_key_name(machine.id)
client_name = f"spire-client-{machine.id}" client_name = f"spire-client-{machine.id}"
try: try:
spire_npub = await admin_client.create_new_key(key_name, passphrase) # Each admin RPC is retried past a transient bunker timeout (#38);
# create_new_key is replace-by-name so re-issuing on a timeout is safe,
# and ensure_policy reconciles idempotently (get_policies → reuse/create).
spire_npub = await _bunker_retry(lambda: admin_client.create_new_key(key_name, passphrase))
spire_pubkey_hex = npub_to_hex(spire_npub) spire_pubkey_hex = npub_to_hex(spire_npub)
policy_id = await ensure_policy( policy_id = await _bunker_retry(
lambda: ensure_policy(
admin_client, admin_client,
name=SPIRE_POLICY_NAME, name=SPIRE_POLICY_NAME,
rules=SPIRE_POLICY_RULES, rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
) )
await admin_client.create_new_token( )
await _bunker_retry(
lambda: admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours key_name, client_name, policy_id, duration_hours=duration_hours
) )
tokens = await admin_client.get_key_tokens(key_name) )
# retry_empty: a just-issued token can list empty before the write lands.
tokens = await _bunker_retry(lambda: admin_client.get_key_tokens(key_name), retry_empty=True)
except NsecBunkerNotConfiguredError as exc: except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc: except NsecBunkerError as exc:
@ -233,15 +359,31 @@ async def pair_spire(
token = _recover_token(tokens, client_name) token = _recover_token(tokens, client_name)
_, _, secret = token.partition("#") _, _, 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 = ( bunker_url = (
f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}" f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}"
f"&secret={quote(secret, safe='')}" f"&secret={quote(secret, safe='')}"
) )
seed_url = build_seed_url( seed_url = build_seed_url(
spire_npub=spire_npub, spire_npub=spire_npub,
spire_pubkey_hex=spire_pubkey_hex, lnbits_npub=lnbits_npub,
bunker_url=bunker_url, bunker_secret=secret,
relays=relays, relays=relays,
bunker_relay=relay,
) )
return PairResult( return PairResult(
spire_npub=spire_npub, spire_npub=spire_npub,

View file

@ -839,12 +839,21 @@ window.app = Vue.createApp({
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Pair / revoke spire (S0 / #9, #12) // Pair / revoke spire (S0 / #9, #12)
// ----------------------------------------------------------------- // -----------------------------------------------------------------
openPairDialog(machine) { async openPairDialog(machine) {
this.pairDialog.machine = machine this.pairDialog.machine = machine
this.pairDialog.relays = '' this.pairDialog.relays = ''
this.pairDialog.durationHours = null this.pairDialog.durationHours = null
this.pairDialog.result = null this.pairDialog.result = null
this.pairDialog.show = true this.pairDialog.show = true
// Pre-fill with this lnbits' default relay (the relay its nostr transport
// listens on, derived from lnbits_baseurl). The operator can override or
// clear it; if left blank the backend fills in the same default.
try {
const {data} = await LNbits.api.request('GET', `${API}/default-relay`)
if (data && data.relay) this.pairDialog.relays = data.relay
} catch (e) {
// Non-fatal: leave blank; the backend still defaults on submit.
}
}, },
async submitPair() { async submitPair() {
@ -852,14 +861,12 @@ window.app = Vue.createApp({
.split(/[\s,]+/) .split(/[\s,]+/)
.map(s => s.trim()) .map(s => s.trim())
.filter(Boolean) .filter(Boolean)
if (!relays.length) { // relays is optional — blank falls back to the transport relay
Quasar.Notify.create({ // server-side (the pre-filled default), so no client-side requirement.
type: 'negative', const body = {}
message: 'At least one relay is required' if (relays.length) {
}) body.relays = relays
return
} }
const body = {relays}
if (this.pairDialog.durationHours) { if (this.pairDialog.durationHours) {
body.duration_hours = Number(this.pairDialog.durationHours) body.duration_hours = Number(this.pairDialog.durationHours)
} }

View file

@ -868,7 +868,7 @@
<q-input <q-input
v-model="pairDialog.relays" v-model="pairDialog.relays"
label="Relay(s) for the spire's events" label="Relay(s) for the spire's events"
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>" hint="Pre-filled with the relay this instance's nostr transport listens on. Leave as-is unless the spire must use a different relay. One per line; blank uses the default."
type="textarea" autogrow type="textarea" autogrow
class="q-mb-md" class="q-mb-md"
dense outlined></q-input> dense outlined></q-input>

View file

@ -66,7 +66,9 @@ def _wire(monkeypatch, *, pair="ok"):
async def fake_owned(machine_id, user_id): async def fake_owned(machine_id, user_id):
return _machine() return _machine()
async def fake_pair(machine, *, relays, admin_client, duration_hours=None): async def fake_pair(
machine, *, relays, admin_client, bunker_relay=None, duration_hours=None
):
if pair == "error": if pair == "error":
raise PairingError("boom") raise PairingError("boom")
return _result() return _result()
@ -80,11 +82,18 @@ def _wire(monkeypatch, *, pair="ok"):
state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name) state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name)
return _machine(npub=machine_npub) 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, "_machine_owned_by", fake_owned)
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
monkeypatch.setattr(views_api, "pair_spire", fake_pair) monkeypatch.setattr(views_api, "pair_spire", fake_pair)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision) monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision)
monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist) monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist)
monkeypatch.setattr(views_api, "get_super_config", fake_super_config)
return state return state
@ -104,11 +113,12 @@ def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1") assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1")
def test_pair_empty_relays_rejected(monkeypatch): def test_pair_empty_relays_accepted(monkeypatch):
# Empty/omitted relays are no longer a 400 — pair_spire defaults them to the
# transport relay (and validates), so the endpoint passes them through.
_wire(monkeypatch) _wire(monkeypatch)
with pytest.raises(HTTPException) as ei: result = _call([])
_call([]) assert result.seed_url == "spire-seed:v1:abc"
assert ei.value.status_code == 400
def test_pair_failure_maps_to_bad_gateway(monkeypatch): def test_pair_failure_maps_to_bad_gateway(monkeypatch):
@ -165,3 +175,18 @@ def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
_call_revoke() _call_revoke()
assert ei.value.status_code == 502 assert ei.value.status_code == 502
assert state["unpaired"] is None # not persisted on failure assert state["unpaired"] is None # not persisted on failure
def test_default_relay_endpoint_returns_transport_relay():
from lnbits.settings import settings
prev_base = settings.lnbits_baseurl
prev_relays = settings.nostr_transport_relays
try:
settings.lnbits_baseurl = "https://lnbits.example.com/"
settings.nostr_transport_relays = ["ws://localhost:5001/nostrrelay/test"]
result = asyncio.run(views_api.api_default_relay(SimpleNamespace(id="op1")))
assert result == {"relay": "wss://lnbits.example.com/nostrrelay/test"}
finally:
settings.lnbits_baseurl = prev_base
settings.nostr_transport_relays = prev_relays

View file

@ -17,6 +17,7 @@ from datetime import datetime, timezone
import pytest import pytest
from lnbits.core.services.nsec_bunker import NsecBunkerError from lnbits.core.services.nsec_bunker import NsecBunkerError
from lnbits.settings import settings
from lnbits.utils.nostr import hex_to_npub from lnbits.utils.nostr import hex_to_npub
from ..models import Machine from ..models import Machine
@ -27,19 +28,43 @@ from ..pairing import (
SPIRE_POLICY_RULES, SPIRE_POLICY_RULES,
PairingError, PairingError,
build_seed_url, build_seed_url,
default_relay_endpoint,
pair_spire, pair_spire,
revoke_spire, revoke_spire,
spire_key_name, spire_key_name,
) )
_BASEURL = "https://lnbits.example.com/"
_TRANSPORT_RELAY = "ws://localhost:5001/nostrrelay/test"
# baseurl host (wss://lnbits.example.com) + transport relay path (/nostrrelay/test)
_DEFAULT_RELAY = "wss://lnbits.example.com/nostrrelay/test"
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) _NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" _SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) _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"] _RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"]
_BUNKER_RELAY = "wss://bunker.internal/relay" _BUNKER_RELAY = "wss://bunker.internal/relay"
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret _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
prev_base = settings.lnbits_baseurl
prev_relays = settings.nostr_transport_relays
settings.nostr_transport_public_key = _LNBITS_HEX
settings.lnbits_baseurl = _BASEURL
settings.nostr_transport_relays = [_TRANSPORT_RELAY]
yield
settings.nostr_transport_public_key = prev
settings.lnbits_baseurl = prev_base
settings.nostr_transport_relays = prev_relays
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clear_policy_cache(): def _clear_policy_cache():
# lnbits' ensure_policy caches resolved policy ids on # lnbits' ensure_policy caches resolved policy ids on
@ -157,18 +182,28 @@ def test_bunker_url_carries_pubkey_relay_secret():
assert "secret=topsecret" in result.bunker_url 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(): 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 result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret
assert result.seed_url.startswith(SEED_URL_SCHEME) payload = _decode_seed(result.seed_url)
blob = result.seed_url[len(SEED_URL_SCHEME) :]
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
assert payload == { assert payload == {
"v": 1, "v": 1,
"spire_npub": _SPIRE_NPUB, "spire_npub": _SPIRE_NPUB,
"spire_pubkey": _SPIRE_HEX, "lnbits_npub": _LNBITS_NPUB,
"bunker_url": result.bunker_url, "bunker_secret": "zzz",
"relays": _RELAYS, "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(): def test_fresh_policy_adds_kindless_nip44_rules():
@ -218,6 +253,99 @@ def test_malformed_token_raises():
_pair(bunker) _pair(bunker)
def test_get_key_tokens_retries_past_a_transient_empty(monkeypatch):
# A slow bunker can list no tokens right after create_new_token; retry.
import spirekeeper.pairing as pairing_mod
async def _no_sleep(*_a, **_k):
pass
monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep)
bunker = FakeBunker(token_secret="s") # pragma: allowlist secret
real = bunker.get_key_tokens
calls = {"n": 0}
async def flaky(key_name):
calls["n"] += 1
return [] if calls["n"] == 1 else await real(key_name)
bunker.get_key_tokens = flaky
result = _pair(bunker)
assert calls["n"] == 2 # empty once, then the token
assert result.spire_npub == _SPIRE_NPUB
def test_pair_raises_when_tokens_stay_empty(monkeypatch):
import spirekeeper.pairing as pairing_mod
async def _no_sleep(*_a, **_k):
pass
monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep)
bunker = FakeBunker()
calls = {"n": 0}
async def always_empty(key_name):
calls["n"] += 1
return []
bunker.get_key_tokens = always_empty
with pytest.raises(PairingError, match="no tokens"):
_pair(bunker)
assert calls["n"] == 5 # exhausted all attempts
def test_create_new_key_retries_past_transient_timeout(monkeypatch):
# A bunker timeout on any admin RPC (here create_new_key) is transient (#38).
import spirekeeper.pairing as pairing_mod
from lnbits.core.services.nsec_bunker import NsecBunkerTimeoutError
async def _no_sleep(*_a, **_k):
pass
monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep)
bunker = FakeBunker(token_secret="s") # pragma: allowlist secret
real = bunker.create_new_key
calls = {"n": 0}
async def flaky(name, passphrase):
calls["n"] += 1
if calls["n"] == 1:
raise NsecBunkerTimeoutError("no response for 'create_new_key' within 15.0s")
return await real(name, passphrase)
bunker.create_new_key = flaky
result = _pair(bunker)
assert calls["n"] == 2 # timed out once, then succeeded
assert result.spire_npub == _SPIRE_NPUB
def test_bunker_rejection_fails_fast_without_retry(monkeypatch):
# A real rejection (NsecBunkerRpcError) is terminal — no retry.
import spirekeeper.pairing as pairing_mod
from lnbits.core.services.nsec_bunker import NsecBunkerRpcError
async def _no_sleep(*_a, **_k):
pass
monkeypatch.setattr(pairing_mod.asyncio, "sleep", _no_sleep)
bunker = FakeBunker()
calls = {"n": 0}
async def rejecting(name, passphrase):
calls["n"] += 1
raise NsecBunkerRpcError("policy rejected")
bunker.create_new_key = rejecting
with pytest.raises(PairingError, match="bunker admin RPC failed"):
_pair(bunker)
assert calls["n"] == 1 # terminal — not retried
def test_bunker_relay_defaults_to_spire_event_relay(): def test_bunker_relay_defaults_to_spire_event_relay():
"""No explicit bunker_relay -> the relay baked into bunker_url is the spire's """No explicit bunker_relay -> the relay baked into bunker_url is the spire's
own public event relay (relays[0]), NOT lnbits's internal bunker URL. This own public event relay (relays[0]), NOT lnbits's internal bunker URL. This
@ -240,7 +368,7 @@ def test_bunker_relay_defaults_to_spire_event_relay():
assert "127.0.0.1" not in result.bunker_url assert "127.0.0.1" not in result.bunker_url
def test_missing_relay_or_passphrase_raises(): def test_missing_passphrase_raises():
with pytest.raises(PairingError, match="PASSPHRASE"): with pytest.raises(PairingError, match="PASSPHRASE"):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
@ -251,13 +379,80 @@ def test_missing_relay_or_passphrase_raises():
keystore_passphrase="", keystore_passphrase="",
) )
) )
with pytest.raises(PairingError, match="relay is required"):
def test_relays_default_to_transport_relay():
# No relays given → derived from the transport relay, re-homed on baseurl.
result = asyncio.run(
pair_spire(
_machine(),
admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret
keystore_passphrase=_PASSPHRASE,
)
)
assert _decode_seed(result.seed_url)["relays"] == [_DEFAULT_RELAY]
def test_default_relay_endpoint_rehomes_loopback_transport_relay():
prev_base = settings.lnbits_baseurl
prev_relays = settings.nostr_transport_relays
try:
# loopback transport relay → re-homed on the base URL host
settings.nostr_transport_relays = ["ws://localhost:5001/nostrrelay/test"]
settings.lnbits_baseurl = "http://192.168.0.32:5001"
assert default_relay_endpoint() == "ws://192.168.0.32:5001/nostrrelay/test"
settings.lnbits_baseurl = "https://lnbits.example.com/"
assert default_relay_endpoint() == _DEFAULT_RELAY
# loopback relay but no base URL → can't re-home → None
settings.lnbits_baseurl = ""
assert default_relay_endpoint() is None
finally:
settings.lnbits_baseurl = prev_base
settings.nostr_transport_relays = prev_relays
def test_default_relay_endpoint_passes_through_reachable_transport_relay():
prev = settings.nostr_transport_relays
try:
# already remote-reachable → used as-is, base URL irrelevant
settings.nostr_transport_relays = ["wss://relay.aiolabs.dev"]
assert default_relay_endpoint() == "wss://relay.aiolabs.dev"
settings.nostr_transport_relays = []
assert default_relay_endpoint() is None
finally:
settings.nostr_transport_relays = prev
def test_no_relays_and_no_transport_relay_raises():
prev = settings.nostr_transport_relays
try:
settings.nostr_transport_relays = []
with pytest.raises(PairingError, match="none could be derived"):
asyncio.run(
pair_spire(
_machine(), admin_client=FakeBunker(), keystore_passphrase=_PASSPHRASE
)
)
finally:
settings.nostr_transport_relays = prev
@pytest.mark.parametrize(
"bad_relay, match",
[
("As://192.168.0.32:5001/x", "ws://"), # QR misread ws://→As://
("http://192.168.0.32:5001/x", "ws://"),
("ws://localhost:5001/nostrrelay/test", "localhost"),
("ws://127.0.0.1:5001/nostrrelay/test", "localhost"),
],
)
def test_rejects_bad_relay(bad_relay, match):
with pytest.raises(PairingError, match=match):
asyncio.run( asyncio.run(
pair_spire( pair_spire(
_machine(), _machine(),
relays=[], relays=[bad_relay],
admin_client=FakeBunker(), admin_client=FakeBunker(),
bunker_relay=_BUNKER_RELAY,
keystore_passphrase=_PASSPHRASE, keystore_passphrase=_PASSPHRASE,
) )
) )
@ -266,14 +461,40 @@ def test_missing_relay_or_passphrase_raises():
def test_build_seed_url_roundtrip(): def test_build_seed_url_roundtrip():
url = build_seed_url( url = build_seed_url(
spire_npub=_SPIRE_NPUB, spire_npub=_SPIRE_NPUB,
spire_pubkey_hex=_SPIRE_HEX, lnbits_npub=_LNBITS_NPUB,
bunker_url="bunker://x?relay=r&secret=s", bunker_secret="s", # pragma: allowlist secret
relays=_RELAYS, relays=_RELAYS,
bunker_relay=_BUNKER_RELAY,
) )
blob = url[len(SEED_URL_SCHEME) :] payload = _decode_seed(url)
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) assert payload["spire_npub"] == _SPIRE_NPUB
assert payload["spire_pubkey"] == _SPIRE_HEX assert payload["lnbits_npub"] == _LNBITS_NPUB
assert payload["bunker_secret"] == "s"
assert payload["relays"] == _RELAYS 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(): def test_pair_threads_duration_hours():

View file

@ -33,6 +33,7 @@ from .pairing import (
PairResult, PairResult,
PairingError, PairingError,
RevokeResult, RevokeResult,
default_relay_endpoint,
pair_spire, pair_spire,
revoke_spire, revoke_spire,
) )
@ -297,6 +298,15 @@ async def api_create_machine(
return machine return machine
@spirekeeper_api_router.get("/api/v1/dca/default-relay")
async def api_default_relay(user: User = Depends(check_user_exists)) -> dict:
"""The relay a pairing seed defaults to when the operator leaves it blank —
the relay the transport listens on, derived from the transport config
(bitspire#70). The pair dialog pre-fills it. `None` if it can't be derived."""
_ = user
return {"relay": default_relay_endpoint()}
@spirekeeper_api_router.post( @spirekeeper_api_router.post(
"/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult "/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult
) )
@ -315,8 +325,8 @@ async def api_pair_machine(
`duration_hours` (optional) time-bounds the token; revoke via the `duration_hours` (optional) time-bounds the token; revoke via the
sibling `POST .../revoke` endpoint.""" sibling `POST .../revoke` endpoint."""
machine = await _machine_owned_by(machine_id, user.id) machine = await _machine_owned_by(machine_id, user.id)
if not data.relays: # relays may be omitted — pair_spire defaults to the transport relay and
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") # validates reachability (raises PairingError → 502).
try: try:
async with NsecBunkerAdminClient.from_settings() as client: async with NsecBunkerAdminClient.from_settings() as client: