Compare commits
No commits in common. "9dc4d09973e9b6e1fe874d78aaeeaf03f2ccb940" and "a5ab02e4b6b500522e020840c791c518c4a91eb1" have entirely different histories.
9dc4d09973
...
a5ab02e4b6
3 changed files with 22 additions and 115 deletions
57
pairing.py
57
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
|
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, slimmed in bitspire#70):
|
Seed URL wire format (contract shared with bitspire#52):
|
||||||
|
|
||||||
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
|
||||||
"lnbits_npub": "npub1…", # this lnbits' nostr-transport server id
|
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience)
|
||||||
"bunker_secret": "<sec>", # one-shot NIP-46 connect token
|
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>",
|
||||||
"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
|
||||||
|
|
@ -54,7 +48,6 @@ 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
|
||||||
|
|
@ -143,27 +136,15 @@ 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:
|
||||||
"""Build the slim seed URL (bitspire#70). The pubkey rides once as
|
payload = {
|
||||||
`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,
|
||||||
"lnbits_npub": lnbits_npub,
|
"spire_pubkey": spire_pubkey_hex,
|
||||||
"bunker_secret": bunker_secret,
|
"bunker_url": bunker_url,
|
||||||
"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()
|
||||||
|
|
@ -252,31 +233,15 @@ 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,
|
||||||
lnbits_npub=lnbits_npub,
|
spire_pubkey_hex=spire_pubkey_hex,
|
||||||
bunker_secret=secret,
|
bunker_url=bunker_url,
|
||||||
relays=relays,
|
relays=relays,
|
||||||
bunker_relay=relay,
|
|
||||||
)
|
)
|
||||||
return PairResult(
|
return PairResult(
|
||||||
spire_npub=spire_npub,
|
spire_npub=spire_npub,
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,7 @@ 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(
|
async def fake_pair(machine, *, relays, admin_client, duration_hours=None):
|
||||||
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()
|
||||||
|
|
@ -82,18 +80,11 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ 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
|
||||||
|
|
@ -36,23 +35,11 @@ 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
|
||||||
|
|
@ -170,28 +157,18 @@ 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
|
||||||
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 == {
|
assert payload == {
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"spire_npub": _SPIRE_NPUB,
|
"spire_npub": _SPIRE_NPUB,
|
||||||
"lnbits_npub": _LNBITS_NPUB,
|
"spire_pubkey": _SPIRE_HEX,
|
||||||
"bunker_secret": "zzz",
|
"bunker_url": result.bunker_url,
|
||||||
"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():
|
||||||
|
|
@ -289,40 +266,14 @@ 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,
|
||||||
lnbits_npub=_LNBITS_NPUB,
|
spire_pubkey_hex=_SPIRE_HEX,
|
||||||
bunker_secret="s", # pragma: allowlist secret
|
bunker_url="bunker://x?relay=r&secret=s",
|
||||||
relays=_RELAYS,
|
relays=_RELAYS,
|
||||||
bunker_relay=_BUNKER_RELAY,
|
|
||||||
)
|
)
|
||||||
payload = _decode_seed(url)
|
blob = url[len(SEED_URL_SCHEME) :]
|
||||||
assert payload["spire_npub"] == _SPIRE_NPUB
|
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
||||||
assert payload["lnbits_npub"] == _LNBITS_NPUB
|
assert payload["spire_pubkey"] == _SPIRE_HEX
|
||||||
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():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue