diff --git a/crud.py b/crud.py index bf51ecd..9646f8d 100644 --- a/crud.py +++ b/crud.py @@ -202,37 +202,6 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine | return await get_machine(machine_id) -async def set_machine_pairing( - machine_id: str, - *, - machine_npub: str, - bunker_spire_key_name: str, - paired_at: datetime, -) -> Machine | None: - """Persist the result of a (re-)pair: the bunker-minted spire identity - becomes the machine's npub (so lnbits' path-B roster routes it), and we - record the bunker key name + pair time. Stored as lowercase hex — the - roster + collision guard normalise either form, hex is canonical.""" - await db.execute( - """ - UPDATE spirekeeper.dca_machines - SET machine_npub = :npub, - bunker_spire_key_name = :key_name, - paired_at = :paired_at, - updated_at = :updated_at - WHERE id = :id - """, - { - "npub": machine_npub.lower(), - "key_name": bunker_spire_key_name, - "paired_at": paired_at, - "updated_at": datetime.now(), - "id": machine_id, - }, - ) - return await get_machine(machine_id) - - async def delete_machine(machine_id: str) -> None: await db.execute( "DELETE FROM spirekeeper.dca_machines WHERE id = :id", diff --git a/migrations.py b/migrations.py index da8ba07..b8e6ec0 100644 --- a/migrations.py +++ b/migrations.py @@ -735,44 +735,3 @@ async def m009_split_fee_fractions_by_direction(db): await db.execute( "ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction" ) - - -async def m010_add_machine_bunker_pairing(db): - """Add NIP-46 bunker-pairing columns to dca_machines for seed-URL - pairing (S0 / aiolabs/spirekeeper#9; spire-side aiolabs/bitspire#52). - - Under the chosen model (A1, decided 2026-06-16), the spire's signing - key lives inside the operator's nsecbunkerd rather than on the spire's - disk. `pair_machine` mints a per-spire key in the bunker, issues a - scoped NIP-46 connect token, and hands the spire a one-shot seed URL - embedding a `bunker://` connection. The spire then self-signs all its - events (kind-21000 RPC, kind-30078 beacon/cassette-state) as its own - bunker-held key; lnbits' path-B roster routes that npub to the - operator's wallet. - - ("spire" = a bitSpire machine; the legacy Lamassu term was "ATM".) - - - bunker_spire_key_name — the spire's key name inside the bunker - (`spire-`). Used to re-issue a connect token on - re-pair and (once the admin client grows a revoke RPC) to revoke - spire access. - - paired_at — timestamp of the last successful pair. NULL = the - machine row exists but no bunker key has been minted yet. - - Both nullable: machines created before this migration, and registered - -but-never-paired machines, carry NULL until first pair. Idempotent - column-probe pattern (same shape as m009). - """ - additions = [ - ("dca_machines", "bunker_spire_key_name", "TEXT"), - ("dca_machines", "paired_at", "TIMESTAMP"), - ] - for table, col, coltype in additions: - try: - await db.fetchone(f"SELECT {col} FROM spirekeeper.{table} LIMIT 1") - continue - except Exception: - pass - await db.execute( - f"ALTER TABLE spirekeeper.{table} ADD COLUMN {col} {coltype}" - ) diff --git a/models.py b/models.py index 68ffdd2..c158fba 100644 --- a/models.py +++ b/models.py @@ -56,9 +56,6 @@ class Machine(BaseModel): is_active: bool operator_cash_in_fee_fraction: float = 0.0 operator_cash_out_fee_fraction: float = 0.0 - # NIP-46 bunker pairing (S0 / #9). NULL until the spire is first paired. - bunker_spire_key_name: str | None = None - paired_at: datetime | None = None created_at: datetime updated_at: datetime @@ -81,15 +78,6 @@ class UpdateMachineData(BaseModel): return round(float(v), 4) -class PairMachineData(BaseModel): - """Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays - the spire will use for its own events (kind-21000/30078) — typically the - operator's nostrrelay; the bunker connection relay is added separately - from the lnbits bunker settings.""" - - relays: list[str] - - # ============================================================================= # DCA Clients — LP registrations, scoped per (machine, user). # ============================================================================= diff --git a/pairing.py b/pairing.py deleted file mode 100644 index 833de3a..0000000 --- a/pairing.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Seed-URL pairing for bitSpire machines (S0 / aiolabs/spirekeeper#9), model A1. - -Mints a per-spire signing key *inside the operator's nsecbunkerd*, issues a -scoped NIP-46 connect token, and builds the one-shot **seed URL** the spire -redeems at first boot. The spire then self-signs all of its own events -(kind-21000 cash RPC, kind-30078 beacon + cassette-state, CLINK 21001-21003) -as that bunker-held key; lnbits' path-B roster (`nostr_transport/roster.py`) -maps the spire npub to the operator's wallet. No nsec ever lands on the -spire's disk. - -Division of labour (vs. lnbits' `RemoteBunkerSigner.provision`, which is the -reference for the admin chain): - - spirekeeper (here) spire, at first boot (bitspire#52) - ────────────────── ────────────────────────────────── - 1. create_new_key 5. NIP-46 connect — redeem the token with a - 2. ensure_policy freshly-generated *client* keypair; bunker - 3. create_new_token binds (client_pubkey → spire key). The - 4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the - (package token in URL) signing key never leaves the bunker. - -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): - - spire-seed:v1: json = { - "v": 1, - "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 - } -""" - -from __future__ import annotations - -import base64 -import json -from urllib.parse import quote - -from lnbits.core.services.nsec_bunker import ( - NsecBunkerAdminClient, - NsecBunkerError, - NsecBunkerNotConfiguredError, - npub_to_hex, -) -from lnbits.core.signers.remote_bunker import ensure_policy -from lnbits.settings import settings -from pydantic import BaseModel - -from .models import Machine - -SEED_URL_SCHEME = "spire-seed:v1:" - -# Policy granted to every spire's connect token. Scoped to exactly what a -# bitSpire signs as itself: -# - 21000 nostr-transport cash RPC envelope to lnbits -# - 21001-21003 CLINK Offer / Debit / Manage (payment flow) -# - 30078 NIP-78 beacon + bitspire-cassettes-state hello-event -# Kind-scoped rules go in create_new_policy; kind-less methods (nip44, for -# encrypting cassette-state to the operator) are added via add_policy_rule -# because nsecbunkerd's create_new_policy chokes on null `kind` -# (rule.kind.toString()). Mirrors lnbits' DEFAULT_POLICY_* split. -# -# NOTE (reconcile when bitspire#52 lands): confirm this kind set against the -# spire's actual createSignedEvent / finalizeEvent call sites. Over-granting -# here only widens what a spire may sign *as its own key* — low blast radius — -# but under-granting makes the bunker reject the spire's events. -SPIRE_POLICY_NAME = "spirekeeper-spire" -SPIRE_POLICY_RULES = [ - {"method": "sign_event", "kind": 21000}, - {"method": "sign_event", "kind": 21001}, - {"method": "sign_event", "kind": 21002}, - {"method": "sign_event", "kind": 21003}, - {"method": "sign_event", "kind": 30078}, -] -SPIRE_POLICY_METHODS_NO_KIND = ["nip44_encrypt", "nip44_decrypt"] - - -class PairingError(Exception): - """Pairing could not be completed (bunker unreachable, misconfigured, - or returned an unusable response). The caller maps this to a 4xx/5xx; - no machine state is mutated on failure.""" - - -class PairResult(BaseModel): - """Output of a successful pair. The API layer persists - `bunker_spire_key_name` + `spire_npub` (→ machine_npub) + `paired_at`, - and returns `seed_url` to the operator (QR + copy).""" - - spire_npub: str - spire_pubkey_hex: str - bunker_key_name: str - bunker_url: str - seed_url: str - - -def spire_key_name(machine_id: str) -> str: - """The spire's key name in the bunker keystore. Stable across re-pairs - so re-issuing a token reuses the same underlying key (create_new_key - is replace-by-name on the bunker side).""" - return f"spire-{machine_id}" - - -def _recover_token(tokens: list[dict], client_name: str) -> str: - """Pull the freshly-issued `#` token out of the bunker's - `get_key_tokens` response. Match by client name when the bunker - serializes it; otherwise fall back to the most-recent entry (same - defensiveness as lnbits' provision()).""" - matching = [ - t - for t in tokens - if t.get("clientName") == client_name or t.get("client_name") == client_name - ] or tokens - if not matching: - raise PairingError("bunker returned no tokens after create_new_token") - token = matching[-1].get("token") - if not isinstance(token, str) or "#" not in token: - raise PairingError(f"bunker returned a malformed token: {token!r}") - return token - - -def build_seed_url( - *, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] -) -> str: - payload = { - "v": 1, - "spire_npub": spire_npub, - "spire_pubkey": spire_pubkey_hex, - "bunker_url": bunker_url, - "relays": relays, - } - blob = ( - base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) - .decode() - .rstrip("=") - ) - return SEED_URL_SCHEME + blob - - -async def pair_spire( - machine: Machine, - *, - relays: list[str], - admin_client: NsecBunkerAdminClient, - bunker_relay: str | None = None, - keystore_passphrase: str | None = None, -) -> PairResult: - """Mint a bunker-held key + scoped connect token for `machine` and - return the seed URL the spire redeems at first boot. - - `admin_client` must already be connected (the caller owns the - `async with NsecBunkerAdminClient.from_settings()` context) — keeps - connection lifecycle out of the orchestration so this is unit-testable - with a fake client. - - `bunker_relay` / `keystore_passphrase` default to the lnbits bunker - settings; injectable for tests. `relays` are the relays the spire will - use for its *own* events (kind-21000/30078) — typically the operator's - nostrrelay; supplied by the API layer. - - Raises PairingError on any bunker failure; no state is persisted here - (the API layer persists on success). - """ - relay = ( - bunker_relay if bunker_relay is not None else settings.lnbits_nsec_bunker_url - ) - passphrase = ( - keystore_passphrase - if keystore_passphrase is not None - else settings.lnbits_nsec_bunker_keystore_passphrase - ) - if not relay: - raise PairingError( - "LNBITS_NSEC_BUNKER_URL is not set — cannot build a spire bunker connection" - ) - if not passphrase: - raise PairingError( - "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " - "cannot mint a spire key" - ) - if not relays: - raise PairingError("at least one relay is required for the seed URL") - - key_name = spire_key_name(machine.id) - client_name = f"spire-client-{machine.id}" - - try: - spire_npub = await admin_client.create_new_key(key_name, passphrase) - spire_pubkey_hex = npub_to_hex(spire_npub) - policy_id = await ensure_policy( - admin_client, - name=SPIRE_POLICY_NAME, - rules=SPIRE_POLICY_RULES, - methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, - ) - await admin_client.create_new_token(key_name, client_name, policy_id) - tokens = await admin_client.get_key_tokens(key_name) - except NsecBunkerNotConfiguredError as exc: - raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc - except NsecBunkerError as exc: - raise PairingError(f"bunker admin RPC failed during pairing: {exc}") from exc - - token = _recover_token(tokens, client_name) - _, _, secret = token.partition("#") - - 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, - spire_pubkey_hex=spire_pubkey_hex, - bunker_url=bunker_url, - relays=relays, - ) - return PairResult( - spire_npub=spire_npub, - spire_pubkey_hex=spire_pubkey_hex, - bunker_key_name=key_name, - bunker_url=bunker_url, - seed_url=seed_url, - ) diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py deleted file mode 100644 index d816c2f..0000000 --- a/tests/test_pair_endpoint.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Wiring tests for POST /machines/{id}/pair (S0 / #9). - -The pairing *service* is covered in test_pairing.py with a fake bunker; -here we only exercise the endpoint glue — ownership, the empty-relays -guard, the post-mint collision guard, persistence of the bunker-minted -hex npub, and error mapping — by monkeypatching the module-level deps. -""" - -import asyncio -from datetime import datetime, timezone -from types import SimpleNamespace - -import pytest -from fastapi import HTTPException -from lnbits.utils.nostr import hex_to_npub - -from .. import views_api -from ..models import Machine, PairMachineData -from ..pairing import PairingError, PairResult - -_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) -_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" -_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) - - -def _machine(npub: str = "placeholder") -> Machine: - return Machine( - id="m1", - operator_user_id="op1", - machine_npub=npub, - wallet_id="w1", - name="sintra", - location=None, - fiat_code="EUR", - is_active=True, - created_at=_NOW, - updated_at=_NOW, - ) - - -class _FakeAdmin: - @classmethod - def from_settings(cls): - return cls() - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - -def _result() -> PairResult: - return PairResult( - spire_npub=_SPIRE_NPUB, - spire_pubkey_hex=_SPIRE_HEX, - bunker_key_name="spire-m1", - bunker_url="bunker://x?relay=r&secret=s", # pragma: allowlist secret - seed_url="spire-seed:v1:abc", - ) - - -def _wire(monkeypatch, *, pair="ok"): - state: dict = {"persisted": None, "collision": None} - - async def fake_owned(machine_id, user_id): - return _machine() - - async def fake_pair(machine, *, relays, admin_client): - if pair == "error": - raise PairingError("boom") - return _result() - - async def fake_collision(npub): - state["collision"] = npub - - async def fake_persist( - machine_id, *, machine_npub, bunker_spire_key_name, paired_at - ): - state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name) - return _machine(npub=machine_npub) - - 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) - return state - - -def _call(relays): - user = SimpleNamespace(id="op1") - return asyncio.run( - views_api.api_pair_machine("m1", PairMachineData(relays=relays), user) - ) - - -def test_pair_persists_hex_npub_and_returns_seed(monkeypatch): - state = _wire(monkeypatch) - result = _call(["wss://r"]) - assert result.seed_url == "spire-seed:v1:abc" - # collision guard ran on the bunker-minted hex, and we persisted it as npub - assert state["collision"] == _SPIRE_HEX - assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1") - - -def test_pair_empty_relays_rejected(monkeypatch): - _wire(monkeypatch) - with pytest.raises(HTTPException) as ei: - _call([]) - assert ei.value.status_code == 400 - - -def test_pair_failure_maps_to_bad_gateway(monkeypatch): - state = _wire(monkeypatch, pair="error") - with pytest.raises(HTTPException) as ei: - _call(["wss://r"]) - assert ei.value.status_code == 502 - # nothing persisted on failure - assert state["persisted"] is None diff --git a/tests/test_pairing.py b/tests/test_pairing.py deleted file mode 100644 index 82eb4a2..0000000 --- a/tests/test_pairing.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Unit tests for the seed-URL pairing service (S0 / #9, model A1). - -The bunker admin client is faked — these exercise the orchestration -(create_new_key -> ensure-policy -> create_new_token -> get_key_tokens), -the policy reconciliation, and the seed-URL / bunker:// wire shape, with -no live nsecbunkerd. npub<->hex round-trips through lnbits' real helpers -so the parsing is exercised for real. - -Async is driven via asyncio.run (this venv has no pytest-asyncio), matching -the rest of the suite. -""" - -import asyncio -import base64 -import json -from datetime import datetime, timezone - -import pytest -from lnbits.utils.nostr import hex_to_npub - -from ..models import Machine -from ..pairing import ( - SEED_URL_SCHEME, - SPIRE_POLICY_METHODS_NO_KIND, - SPIRE_POLICY_NAME, - SPIRE_POLICY_RULES, - PairingError, - build_seed_url, - pair_spire, - spire_key_name, -) - -_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) -_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" -_SPIRE_NPUB = hex_to_npub(_SPIRE_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 _clear_policy_cache(): - # lnbits' ensure_policy caches resolved policy ids on - # (admin_pubkey, name); clear between tests so each FakeBunker's - # canned policy state is honoured rather than a stale cached id. - from lnbits.core.signers import remote_bunker - - remote_bunker._POLICY_ID_CACHE.clear() - yield - - -def _machine(mid: str = "m1") -> Machine: - return Machine( - id=mid, - operator_user_id="op1", - machine_npub="placeholder", - wallet_id="w1", - name="sintra", - location=None, - fiat_code="EUR", - is_active=True, - created_at=_NOW, - updated_at=_NOW, - ) - - -class FakeBunker: - """Records calls; returns canned bunker responses.""" - - admin_pubkey = "fake-admin-pubkey" - - # pragma: allowlist secret - def __init__(self, *, policies=None, token_secret="s3cr3t"): - self._policies = policies or [] - self._token_secret = token_secret - self.calls: list[tuple] = [] - self._next_policy_id = 7 - - async def create_new_key(self, name, passphrase): - self.calls.append(("create_new_key", name, passphrase)) - return _SPIRE_NPUB - - async def get_policies(self): - self.calls.append(("get_policies",)) - return list(self._policies) - - async def create_new_policy(self, name, rules): - self.calls.append(("create_new_policy", name, rules)) - pid = self._next_policy_id - self._policies.append({"id": pid, "name": name, "rules": list(rules)}) - return pid - - async def add_policy_rule(self, policy_id, rule): - self.calls.append(("add_policy_rule", policy_id, rule)) - - async def create_new_token(self, key_name, client_name, policy_id): - self.calls.append(("create_new_token", key_name, client_name, policy_id)) - - async def get_key_tokens(self, key_name): - self.calls.append(("get_key_tokens", key_name)) - return [ - { - "clientName": f"spire-client-{key_name.split('-', 1)[1]}", - "token": f"{_SPIRE_NPUB}#{self._token_secret}", - } - ] - - def named(self, name): - return [c for c in self.calls if c[0] == name] - - -def _pair(bunker, machine=None): - return asyncio.run( - pair_spire( - machine or _machine(), - relays=_RELAYS, - admin_client=bunker, - bunker_relay=_BUNKER_RELAY, - keystore_passphrase=_PASSPHRASE, - ) - ) - - -def test_pair_happy_path_mints_key_policy_token(): - bunker = FakeBunker(token_secret="abc123") # pragma: allowlist secret - result = _pair(bunker) - - assert ("create_new_key", "spire-m1", _PASSPHRASE) in bunker.calls - assert result.bunker_key_name == spire_key_name("m1") == "spire-m1" - - assert result.spire_npub == _SPIRE_NPUB - assert result.spire_pubkey_hex == _SPIRE_HEX - - created = bunker.named("create_new_policy") - assert created and created[0][1] == SPIRE_POLICY_NAME - token_call = bunker.named("create_new_token")[0] - assert token_call[1] == "spire-m1" # key_name - assert token_call[2] == "spire-client-m1" # client_name - assert token_call[3] == 7 # policy_id from the fake's create_new_policy - - -def test_bunker_url_carries_pubkey_relay_secret(): - result = _pair(FakeBunker(token_secret="topsecret")) # pragma: allowlist secret - assert result.bunker_url.startswith(f"bunker://{_SPIRE_HEX}?") - assert "relay=wss%3A%2F%2Fbunker.internal%2Frelay" in result.bunker_url - assert "secret=topsecret" in result.bunker_url - - -def test_seed_url_decodes_to_contract(): - result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret - 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, - "spire_pubkey": _SPIRE_HEX, - "bunker_url": result.bunker_url, - "relays": _RELAYS, - } - - -def test_fresh_policy_adds_kindless_nip44_rules(): - bunker = FakeBunker() # no existing policies - _pair(bunker) - added = [c[2]["method"] for c in bunker.named("add_policy_rule")] - # kind-scoped rules went in via create_new_policy; only the kind-less - # nip44 methods are reconciled in via add_policy_rule. - assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND) - - -def test_existing_policy_reused_not_recreated(): - existing = [ - { - "id": 42, - "name": SPIRE_POLICY_NAME, - "rules": [dict(r) for r in SPIRE_POLICY_RULES], - } - ] - bunker = FakeBunker(policies=existing) - _pair(bunker) - assert not bunker.named("create_new_policy") # reused, not recreated - assert bunker.named("create_new_token")[0][3] == 42 # used existing id - added = [c[2]["method"] for c in bunker.named("add_policy_rule")] - assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND) - - -def test_fully_provisioned_policy_adds_nothing(): - rules = [dict(r) for r in SPIRE_POLICY_RULES] + [ - {"method": m, "kind": None} for m in SPIRE_POLICY_METHODS_NO_KIND - ] - bunker = FakeBunker(policies=[{"id": 9, "name": SPIRE_POLICY_NAME, "rules": rules}]) - _pair(bunker) - assert not bunker.named("add_policy_rule") - assert not bunker.named("create_new_policy") - - -def test_malformed_token_raises(): - bunker = FakeBunker() - - async def _bad_tokens(key_name): - _ = key_name - return [{"token": "no-hash-here"}] - - bunker.get_key_tokens = _bad_tokens - with pytest.raises(PairingError, match="malformed token"): - _pair(bunker) - - -def test_missing_relay_or_passphrase_raises(): - with pytest.raises(PairingError, match="LNBITS_NSEC_BUNKER_URL"): - asyncio.run( - pair_spire( - _machine(), - relays=_RELAYS, - admin_client=FakeBunker(), - bunker_relay="", - keystore_passphrase=_PASSPHRASE, - ) - ) - with pytest.raises(PairingError, match="PASSPHRASE"): - asyncio.run( - pair_spire( - _machine(), - relays=_RELAYS, - admin_client=FakeBunker(), - bunker_relay=_BUNKER_RELAY, - keystore_passphrase="", - ) - ) - with pytest.raises(PairingError, match="relay is required"): - asyncio.run( - pair_spire( - _machine(), - relays=[], - admin_client=FakeBunker(), - bunker_relay=_BUNKER_RELAY, - keystore_passphrase=_PASSPHRASE, - ) - ) - - -def test_build_seed_url_roundtrip(): - url = build_seed_url( - spire_npub=_SPIRE_NPUB, - spire_pubkey_hex=_SPIRE_HEX, - bunker_url="bunker://x?relay=r&secret=s", - relays=_RELAYS, - ) - 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 diff --git a/views_api.py b/views_api.py index a37df74..079794d 100644 --- a/views_api.py +++ b/views_api.py @@ -5,18 +5,12 @@ # LNbits instance can never see each other's machines, settlements, or # clients. The super-only platform-fee write endpoint lands in P2. -from datetime import datetime, timezone from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException from lnbits.core.crud import get_wallet from lnbits.core.crud.users import get_account_by_pubkey from lnbits.core.models import User -from lnbits.core.services.nsec_bunker import ( - NsecBunkerAdminClient, - NsecBunkerError, - NsecBunkerNotConfiguredError, -) from lnbits.decorators import check_super_user, check_user_exists from lnbits.utils.nostr import normalize_public_key @@ -29,7 +23,6 @@ from .cassette_transport import ( publish_to_atm, ) from .fee_transport import publish_fee_config -from .pairing import PairResult, PairingError, pair_spire from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -62,7 +55,6 @@ from .crud import ( lp_is_onboarded, replace_commission_splits, reset_settlement_for_retry, - set_machine_pairing, update_cassette_config, update_dca_client, update_deposit, @@ -88,7 +80,6 @@ from .models import ( DcaPayment, DcaSettlement, Machine, - PairMachineData, PartialDispenseData, PublishCassettesPayload, SetCommissionSplitsData, @@ -283,50 +274,6 @@ async def api_create_machine( return machine -@spirekeeper_api_router.post( - "/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult -) -async def api_pair_machine( - machine_id: str, - data: PairMachineData, - user: User = Depends(check_user_exists), -) -> PairResult: - """Seed-URL pairing (S0 / #9, model A1). Mints a per-spire signing key - inside the operator's nsecbunkerd and returns the one-shot seed URL the - spire redeems at first boot. The bunker-minted key becomes the machine's - npub, so lnbits' path-B roster routes the spire's cash-out RPCs to this - operator's wallet — no nsec ever lands on the spire. - - Re-pair is supported (re-issues a token for the same spire key). Token - revocation + expiry are gated on aiolabs/lnbits#54 (admin-client gaps).""" - machine = await _machine_owned_by(machine_id, user.id) - if not data.relays: - raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") - - try: - async with NsecBunkerAdminClient.from_settings() as client: - result = await pair_spire(machine, relays=data.relays, admin_client=client) - except NsecBunkerNotConfiguredError as exc: - raise HTTPException( - HTTPStatus.SERVICE_UNAVAILABLE, - f"nsecbunkerd is not configured on this LNbits instance: {exc}", - ) from exc - except (PairingError, NsecBunkerError) as exc: - raise HTTPException(HTTPStatus.BAD_GATEWAY, f"pairing failed: {exc}") from exc - - # The bunker-minted identity becomes the machine npub — run the same - # collision guard as create before persisting (fresh keys ~never collide, - # but defence-in-depth keeps the no-collision invariant intact). - await _assert_no_pubkey_collision(result.spire_pubkey_hex) - await set_machine_pairing( - machine_id, - machine_npub=result.spire_pubkey_hex, - bunker_spire_key_name=result.bunker_key_name, - paired_at=datetime.now(timezone.utc), - ) - return result - - @spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) async def api_list_machines( user: User = Depends(check_user_exists),