From bb473f538504c47d7d97c2ee0506569a71df0ce7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 22:47:42 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(pairing):=20m010=20schema=20=E2=80=94?= =?UTF-8?q?=20bunker=20pairing=20columns=20on=20dca=5Fmachines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema checkpoint for seed-URL pairing (S0 / #9; spire-side bitspire#52), model A1 — the spire's signing key lives in the operator's nsecbunkerd, not on the spire's disk. dca_machines gains: - bunker_spire_key_name — the spire's key name in the bunker (spire-); used to re-issue connect tokens on re-pair. - paired_at — last successful pair; NULL = never paired. Both nullable, idempotent column-probe add (m009 pattern). Machine model gains the matching optional fields. Validated on the regtest dev db (columns present, migrations clean); 191 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations.py | 41 +++++++++++++++++++++++++++++++++++++++++ models.py | 3 +++ 2 files changed, 44 insertions(+) diff --git a/migrations.py b/migrations.py index b8e6ec0..da8ba07 100644 --- a/migrations.py +++ b/migrations.py @@ -735,3 +735,44 @@ 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 c158fba..90df810 100644 --- a/models.py +++ b/models.py @@ -56,6 +56,9 @@ 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 -- 2.53.0 From a77f5bcb5c853ba9294a63b89b08922f9d99f7cc Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:17:48 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(pairing):=20bunker=20pairing=20service?= =?UTF-8?q?=20=E2=80=94=20mint=20per-spire=20key=20+=20seed=20URL=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-side producer for seed-URL pairing (S0/#9, model A1). pair_spire() orchestrates the nsecbunkerd admin chain via lnbits' NsecBunkerAdminClient: create_new_key(spire-) -> _ensure_spire_policy -> create_new_token -> get_key_tokens -> package the #secret token into a bunker:// URL + base64url seed URL {spire_npub, spire_pubkey, bunker_url, relays}. The spire later self-signs all its events as that bunker-held key; lnbits' path-B roster maps the npub to the operator wallet — no nsec on the spire. spirekeeper does steps 1-4 only; the NIP-46 connect/bind happens spire-side (bitspire#52) with the spire's own client keypair. Scoped policy 'spirekeeper-spire': sign_event 21000/21001-3/30078 + nip44 (kind-less via add_policy_rule). Local _ensure_spire_policy (no cache) avoids lnbits' admin-pubkey-keyed _ensure_policy cache (policy-name-blind). 9 unit tests with a fake bunker (orchestration, policy reconcile, seed/ bunker:// wire shape, error paths); npub<->hex via lnbits' real helpers. 200 tests green. Known gaps (lnbits NsecBunkerAdminClient): no token-expiry param, no revoke RPC — re-pair works; 'revoke spire access' deferred to a bunker follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- pairing.py | 268 ++++++++++++++++++++++++++++++++++++++++++ tests/test_pairing.py | 240 +++++++++++++++++++++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 pairing.py create mode 100644 tests/test_pairing.py diff --git a/pairing.py b/pairing.py new file mode 100644 index 0000000..6725a80 --- /dev/null +++ b/pairing.py @@ -0,0 +1,268 @@ +"""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_spire_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.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}" + + +async def _ensure_spire_policy(client: NsecBunkerAdminClient) -> int: + """Find-or-create the shared `spirekeeper-spire` policy and reconcile + (add-only) any missing rules. Returns its bunker-assigned id. + + Mirrors lnbits' `_ensure_policy` but with the spire rule set and **no + admin-pubkey cache** — lnbits' cache is keyed on `admin_pubkey` alone + (remote_bunker.py:157), so reusing its helper for a second policy name + would return the wrong (cached lnbits-default) id. Pairing is an + infrequent operator action, so the extra `get_policies` round-trip is + cheap and correct. (Filed as an lnbits follow-up: cache key should + include policy_name.) + + Add-only, never remove: the policy may be shared across spires (and in + principle other consumers); removing a rule would affect them all. + """ + policies = await client.get_policies() + existing = [p for p in policies if p.get("name") == SPIRE_POLICY_NAME] + if existing: + policy = max(existing, key=lambda p: int(p["id"])) + policy_id = int(policy["id"]) + live_rules = policy.get("rules") or [] + else: + policy_id = await client.create_new_policy( + SPIRE_POLICY_NAME, SPIRE_POLICY_RULES + ) + live_rules = list(SPIRE_POLICY_RULES) + + def _norm(method: str, kind) -> tuple[str, str | None]: + # nsecbunkerd stores kind as a string; None = kind-less rule. + return (method, str(kind) if kind is not None else None) + + live_keys = { + _norm(r["method"], r.get("kind")) + for r in live_rules + if isinstance(r, dict) and r.get("method") + } + for rule in SPIRE_POLICY_RULES: + key = _norm(rule["method"], rule.get("kind")) + if key not in live_keys: + await client.add_policy_rule(policy_id, rule) + live_keys.add(key) + for method in SPIRE_POLICY_METHODS_NO_KIND: + key = _norm(method, None) + if key not in live_keys: + await client.add_policy_rule(policy_id, {"method": method}) + live_keys.add(key) + return policy_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_spire_policy(admin_client) + 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_pairing.py b/tests/test_pairing.py new file mode 100644 index 0000000..bda1402 --- /dev/null +++ b/tests/test_pairing.py @@ -0,0 +1,240 @@ +"""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 + + +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.""" + + # 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 -- 2.53.0 From 761f0780537b3ec9194ec869ffdd2525c48dac93 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:39:18 +0200 Subject: [PATCH 3/4] feat(pairing): POST /machines/{id}/pair endpoint (#9) Wires the pairing service into the operator API. api_pair_machine: - _machine_owned_by ownership guard (404 on miss) - opens NsecBunkerAdminClient.from_settings() and runs pair_spire - maps bunker failures: not-configured -> 503, PairingError/NsecBunkerError -> 502 (nothing persisted on failure) - runs _assert_no_pubkey_collision on the bunker-minted hex, then set_machine_pairing persists machine_npub (= minted spire identity, so path-B roster routes it), bunker_spire_key_name, paired_at. Re-pair supported; revoke/expiry gated on aiolabs/lnbits#54. Adds Create... PairMachineData {relays} body, set_machine_pairing CRUD, and 3 endpoint wiring tests (persist+collision, empty-relays 400, failure 502). 203 tests green. Pre-existing black/ruff debt in crud/views_api left untouched (version-drift churn avoided); new code is lint-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crud.py | 31 ++++++++++ models.py | 9 +++ tests/test_pair_endpoint.py | 120 ++++++++++++++++++++++++++++++++++++ views_api.py | 53 ++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 tests/test_pair_endpoint.py diff --git a/crud.py b/crud.py index 9646f8d..bf51ecd 100644 --- a/crud.py +++ b/crud.py @@ -202,6 +202,37 @@ 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/models.py b/models.py index 90df810..68ffdd2 100644 --- a/models.py +++ b/models.py @@ -81,6 +81,15 @@ 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/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py new file mode 100644 index 0000000..d816c2f --- /dev/null +++ b/tests/test_pair_endpoint.py @@ -0,0 +1,120 @@ +"""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/views_api.py b/views_api.py index 079794d..a37df74 100644 --- a/views_api.py +++ b/views_api.py @@ -5,12 +5,18 @@ # 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 @@ -23,6 +29,7 @@ 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, @@ -55,6 +62,7 @@ from .crud import ( lp_is_onboarded, replace_commission_splits, reset_settlement_for_retry, + set_machine_pairing, update_cassette_config, update_dca_client, update_deposit, @@ -80,6 +88,7 @@ from .models import ( DcaPayment, DcaSettlement, Machine, + PairMachineData, PartialDispenseData, PublishCassettesPayload, SetCommissionSplitsData, @@ -274,6 +283,50 @@ 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), -- 2.53.0 From 9c5f07c72e103a6f6c12b0f9498839b48c769fcf Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 18:31:21 +0200 Subject: [PATCH 4/4] refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the public ensure_policy(client, name='spirekeeper-spire', rules=..., methods_no_kind=...) instead of spirekeeper's cache-free _ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on (admin_pubkey, policy_name), so the shared helper no longer returns the wrong (lnbits-default) id for a non-default policy name — the exact reason the duplicate existed. Net -45 LOC, one less fork-divergent reimplementation to keep in sync. Requires lnbits >= the #55 merge (ensure_policy importable) — already true on dev/demo. Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits' _POLICY_ID_CACHE between tests (the shared helper caches, unlike the old local one). 203 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- pairing.py | 59 ++++++------------------------------------- tests/test_pairing.py | 13 ++++++++++ 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/pairing.py b/pairing.py index 6725a80..833de3a 100644 --- a/pairing.py +++ b/pairing.py @@ -14,7 +14,7 @@ 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_spire_policy freshly-generated *client* keypair; bunker + 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. @@ -46,6 +46,7 @@ from lnbits.core.services.nsec_bunker import ( NsecBunkerNotConfiguredError, npub_to_hex, ) +from lnbits.core.signers.remote_bunker import ensure_policy from lnbits.settings import settings from pydantic import BaseModel @@ -103,55 +104,6 @@ def spire_key_name(machine_id: str) -> str: return f"spire-{machine_id}" -async def _ensure_spire_policy(client: NsecBunkerAdminClient) -> int: - """Find-or-create the shared `spirekeeper-spire` policy and reconcile - (add-only) any missing rules. Returns its bunker-assigned id. - - Mirrors lnbits' `_ensure_policy` but with the spire rule set and **no - admin-pubkey cache** — lnbits' cache is keyed on `admin_pubkey` alone - (remote_bunker.py:157), so reusing its helper for a second policy name - would return the wrong (cached lnbits-default) id. Pairing is an - infrequent operator action, so the extra `get_policies` round-trip is - cheap and correct. (Filed as an lnbits follow-up: cache key should - include policy_name.) - - Add-only, never remove: the policy may be shared across spires (and in - principle other consumers); removing a rule would affect them all. - """ - policies = await client.get_policies() - existing = [p for p in policies if p.get("name") == SPIRE_POLICY_NAME] - if existing: - policy = max(existing, key=lambda p: int(p["id"])) - policy_id = int(policy["id"]) - live_rules = policy.get("rules") or [] - else: - policy_id = await client.create_new_policy( - SPIRE_POLICY_NAME, SPIRE_POLICY_RULES - ) - live_rules = list(SPIRE_POLICY_RULES) - - def _norm(method: str, kind) -> tuple[str, str | None]: - # nsecbunkerd stores kind as a string; None = kind-less rule. - return (method, str(kind) if kind is not None else None) - - live_keys = { - _norm(r["method"], r.get("kind")) - for r in live_rules - if isinstance(r, dict) and r.get("method") - } - for rule in SPIRE_POLICY_RULES: - key = _norm(rule["method"], rule.get("kind")) - if key not in live_keys: - await client.add_policy_rule(policy_id, rule) - live_keys.add(key) - for method in SPIRE_POLICY_METHODS_NO_KIND: - key = _norm(method, None) - if key not in live_keys: - await client.add_policy_rule(policy_id, {"method": method}) - live_keys.add(key) - return policy_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 @@ -238,7 +190,12 @@ async def pair_spire( try: spire_npub = await admin_client.create_new_key(key_name, passphrase) spire_pubkey_hex = npub_to_hex(spire_npub) - policy_id = await _ensure_spire_policy(admin_client) + 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: diff --git a/tests/test_pairing.py b/tests/test_pairing.py index bda1402..82eb4a2 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -38,6 +38,17 @@ _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, @@ -56,6 +67,8 @@ def _machine(mid: str = "m1") -> Machine: 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 [] -- 2.53.0