feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)
Some checks failed
ci.yml / feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) (pull_request) Failing after 0s

Mint the new-shape seed the bitspire consumer now expects: the pubkey rides
once as spire_npub (consumer derives the hex + reconstructs bunker_url from
bunker_secret + bunker_relay|relays[0]), and lnbits_npub is embedded so a paired
machine reaches this lnbits' nostr-transport with nothing else provisioned.

- build_seed_url emits {spire_npub, lnbits_npub, bunker_secret, relays} and
  bunker_relay only when it differs from relays[0] (omitted in the common case).
  Drops spire_pubkey + the full bunker_url from the payload.
- pair_spire reads settings.nostr_transport_public_key, hex_to_npub's it, and
  raises PairingError when it's empty (transport not running → can't mint a
  self-sufficient seed). bunker_url is still returned in PairResult for operator
  display / audit; only the seed stops embedding it.

Consumer side: bitspire packages/nostr-client/src/seed.ts + the #70 machine
wiring. Kept as v: 1 (redefined in place; no shipped seed to preserve).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-01 21:16:10 +02:00
commit 9dc4d09973
2 changed files with 105 additions and 21 deletions

View file

@ -23,15 +23,21 @@ 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
@ -48,6 +54,7 @@ from lnbits.core.services.nsec_bunker import (
) )
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
@ -136,15 +143,27 @@ def _recover_token(tokens: list[dict], client_name: str) -> str:
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()
@ -233,15 +252,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

@ -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
@ -35,11 +36,23 @@ from ..pairing import (
_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
settings.nostr_transport_public_key = _LNBITS_HEX
yield
settings.nostr_transport_public_key = prev
@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 +170,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():
@ -266,14 +289,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():