From c651d53419304bb556ed3298fa4b3f66ef22eaea Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 18:10:29 +0200 Subject: [PATCH 01/19] assets: replace extension logo with AIO mark (256x256) Co-Authored-By: Claude Opus 4.8 (1M context) --- static/image/aio.png | Bin 40347 -> 48124 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/static/image/aio.png b/static/image/aio.png index db8823b8d68f8c01eed78c7438bb046ed35bf2da..63dc24b815b25f84d5c551b5ef2f9b7690c51a53 100644 GIT binary patch literal 48124 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 a{p;lnw=(v>Ok!YQVDNPHb6Mw<&;$UCimPY< literal 40347 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 From bb473f538504c47d7d97c2ee0506569a71df0ce7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 22:47:42 +0200 Subject: [PATCH 02/19] =?UTF-8?q?feat(pairing):=20m010=20schema=20?= =?UTF-8?q?=E2=80=94=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 From a77f5bcb5c853ba9294a63b89b08922f9d99f7cc Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:17:48 +0200 Subject: [PATCH 03/19] =?UTF-8?q?feat(pairing):=20bunker=20pairing=20servi?= =?UTF-8?q?ce=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 From 761f0780537b3ec9194ec869ffdd2525c48dac93 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:39:18 +0200 Subject: [PATCH 04/19] 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), From 9c5f07c72e103a6f6c12b0f9498839b48c769fcf Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 18:31:21 +0200 Subject: [PATCH 05/19] 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 [] From a5efdf22a144220913e0039ada37bf455824b199 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 18:51:54 +0200 Subject: [PATCH 06/19] feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the seed-URL pairing in #21 (stacked). (b) TTL — PairMachineData.duration_hours (validated > 0) threads through pair_spire -> create_new_token (lnbits#55). None = non-expiring. (c) Revoke — POST /machines/{id}/revoke -> revoke_spire -> admin_client.revoke_key_user(spire-). Per spirekeeper#22, revoke MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke: lnbits eager-binds (redeems) the connect token at provision, so nsecbunkerd has materialised the policy into per-KeyUser grants its ACL checks BEFORE the Token.revokedAt filter -> token revoke is a silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 = never bound. set_machine_unpaired clears paired_at (keeps npub + bunker_spire_key_name for audit / re-pair). 7 new tests (duration threading + default-None; revoke routes to revoke_key_user and never token-revoke + error mapping; endpoint wiring revoke happy/zero/502). 210 green; new code black/ruff-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crud.py | 18 +++++++++++ models.py | 10 ++++++- pairing.py | 47 ++++++++++++++++++++++++++++- tests/test_pair_endpoint.py | 49 +++++++++++++++++++++++++++++- tests/test_pairing.py | 60 +++++++++++++++++++++++++++++++++++-- views_api.py | 49 +++++++++++++++++++++++++++--- 6 files changed, 223 insertions(+), 10 deletions(-) diff --git a/crud.py b/crud.py index bf51ecd..6c4ef53 100644 --- a/crud.py +++ b/crud.py @@ -233,6 +233,24 @@ async def set_machine_pairing( return await get_machine(machine_id) +async def set_machine_unpaired(machine_id: str) -> Machine | None: + """Mark a machine unpaired after revoking its spire's bunker access + (POST /revoke). Clears `paired_at`; keeps `machine_npub` + + `bunker_spire_key_name` for audit / re-pair. The bunker-side + `KeyUser.revokedAt` (set by `revoke_spire`) is what actually stops the + spire signing — this just records the operator-visible state.""" + await db.execute( + """ + UPDATE spirekeeper.dca_machines + SET paired_at = NULL, + updated_at = :updated_at + WHERE id = :id + """, + {"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 68ffdd2..f56cbcd 100644 --- a/models.py +++ b/models.py @@ -85,9 +85,17 @@ 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.""" + from the lnbits bunker settings. `duration_hours` optionally time-bounds + the spire's connect token (None = non-expiring).""" relays: list[str] + duration_hours: int | None = None + + @validator("duration_hours") + def _positive_duration(cls, v): + if v is not None and v <= 0: + raise ValueError("duration_hours must be positive when set") + return v # ============================================================================= diff --git a/pairing.py b/pairing.py index 833de3a..b65b760 100644 --- a/pairing.py +++ b/pairing.py @@ -97,6 +97,14 @@ class PairResult(BaseModel): seed_url: str +class RevokeResult(BaseModel): + """Output of revoke. `revoked_count` >= 1 = the spire's signing access + is cut (KeyUser.revokedAt set); 0 = nothing was bound (token minted but + the spire never connected).""" + + revoked_count: int + + 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 @@ -147,10 +155,16 @@ async def pair_spire( admin_client: NsecBunkerAdminClient, bunker_relay: str | None = None, keystore_passphrase: str | None = None, + duration_hours: int | None = None, ) -> PairResult: """Mint a bunker-held key + scoped connect token for `machine` and return the seed URL the spire redeems at first boot. + `duration_hours` (optional, aiolabs/lnbits#54 item 2) sets a TTL on the + spire's connect token — the bunker stamps `expiresAt` and rejects the + token once it lapses, forcing a re-pair. None = non-expiring (the only + invalidation path is then revoke, `revoke_spire`). + `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 @@ -196,7 +210,9 @@ async def pair_spire( rules=SPIRE_POLICY_RULES, methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, ) - await admin_client.create_new_token(key_name, client_name, policy_id) + await admin_client.create_new_token( + key_name, client_name, policy_id, duration_hours=duration_hours + ) tokens = await admin_client.get_key_tokens(key_name) except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc @@ -223,3 +239,32 @@ async def pair_spire( bunker_url=bunker_url, seed_url=seed_url, ) + + +async def revoke_spire( + machine: Machine, *, admin_client: NsecBunkerAdminClient +) -> int: + """Revoke a spire's bunker access (the "Revoke spire access" UX, + aiolabs/spirekeeper#9/#12; security model per #22). + + Calls `revoke_key_user` — NOT `revoke_token` / `revoke_key_token`. + lnbits eager-binds (redeems) the connect token at provision time + (aiolabs/lnbits#32), so nsecbunkerd has already materialised the + token's policy into standing per-`KeyUser` `SigningCondition` grants; + its sign-time ACL checks those *before* the `Token.revokedAt` filter, + so revoking the token is a silent no-op (the spire keeps signing). + Only `KeyUser.revokedAt` — set by `revoke_user` / `revoke_key_user` — + actually cuts off signing (verified live 2026-06-18, #22). + + Returns the number of KeyUsers revoked: >= 1 means the spire's signing + access is now cut; 0 means nothing was bound (token minted but the + spire never connected). Raises PairingError on any bunker failure. + """ + try: + return await admin_client.revoke_key_user(spire_key_name(machine.id)) + 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 revoke: {exc}" + ) from exc diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py index d816c2f..0d50d95 100644 --- a/tests/test_pair_endpoint.py +++ b/tests/test_pair_endpoint.py @@ -66,7 +66,7 @@ def _wire(monkeypatch, *, pair="ok"): async def fake_owned(machine_id, user_id): return _machine() - async def fake_pair(machine, *, relays, admin_client): + async def fake_pair(machine, *, relays, admin_client, duration_hours=None): if pair == "error": raise PairingError("boom") return _result() @@ -118,3 +118,50 @@ def test_pair_failure_maps_to_bad_gateway(monkeypatch): assert ei.value.status_code == 502 # nothing persisted on failure assert state["persisted"] is None + + +def _wire_revoke(monkeypatch, *, revoke="ok", count=2): + state = {"unpaired": None} + + async def fake_owned(machine_id, user_id): + return _machine() + + async def fake_revoke(machine, *, admin_client): + if revoke == "error": + raise PairingError("boom") + return count + + async def fake_unpaired(machine_id): + state["unpaired"] = machine_id + return _machine() + + monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned) + monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) + monkeypatch.setattr(views_api, "revoke_spire", fake_revoke) + monkeypatch.setattr(views_api, "set_machine_unpaired", fake_unpaired) + return state + + +def _call_revoke(): + user = SimpleNamespace(id="op1") + return asyncio.run(views_api.api_revoke_machine("m1", user)) + + +def test_revoke_cuts_access_and_marks_unpaired(monkeypatch): + state = _wire_revoke(monkeypatch, count=2) + result = _call_revoke() + assert result.revoked_count == 2 + assert state["unpaired"] == "m1" + + +def test_revoke_zero_when_nothing_bound(monkeypatch): + _wire_revoke(monkeypatch, count=0) + assert _call_revoke().revoked_count == 0 + + +def test_revoke_failure_maps_to_bad_gateway(monkeypatch): + state = _wire_revoke(monkeypatch, revoke="error") + with pytest.raises(HTTPException) as ei: + _call_revoke() + assert ei.value.status_code == 502 + assert state["unpaired"] is None # not persisted on failure diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 82eb4a2..4deb5a0 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -16,6 +16,7 @@ import json from datetime import datetime, timezone import pytest +from lnbits.core.services.nsec_bunker import NsecBunkerError from lnbits.utils.nostr import hex_to_npub from ..models import Machine @@ -27,6 +28,7 @@ from ..pairing import ( PairingError, build_seed_url, pair_spire, + revoke_spire, spire_key_name, ) @@ -70,9 +72,10 @@ class FakeBunker: admin_pubkey = "fake-admin-pubkey" # pragma: allowlist secret - def __init__(self, *, policies=None, token_secret="s3cr3t"): + def __init__(self, *, policies=None, token_secret="s3cr3t", revoke_count=1): self._policies = policies or [] self._token_secret = token_secret + self._revoke_count = revoke_count self.calls: list[tuple] = [] self._next_policy_id = 7 @@ -93,8 +96,16 @@ class FakeBunker: 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 create_new_token( + self, key_name, client_name, policy_id, duration_hours=None + ): + self.calls.append( + ("create_new_token", key_name, client_name, policy_id, duration_hours) + ) + + async def revoke_key_user(self, key_name): + self.calls.append(("revoke_key_user", key_name)) + return self._revoke_count async def get_key_tokens(self, key_name): self.calls.append(("get_key_tokens", key_name)) @@ -251,3 +262,46 @@ def test_build_seed_url_roundtrip(): payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) assert payload["spire_pubkey"] == _SPIRE_HEX assert payload["relays"] == _RELAYS + + +def test_pair_threads_duration_hours(): + bunker = FakeBunker() + asyncio.run( + pair_spire( + _machine(), + relays=_RELAYS, + admin_client=bunker, + bunker_relay=_BUNKER_RELAY, + keystore_passphrase=_PASSPHRASE, + duration_hours=720, + ) + ) + # create_new_token tuple is (name, key, client, policy_id, duration_hours) + assert bunker.named("create_new_token")[0][4] == 720 + + +def test_pair_default_duration_is_none(): + bunker = FakeBunker() + _pair(bunker) # no duration_hours + assert bunker.named("create_new_token")[0][4] is None + + +def test_revoke_spire_calls_revoke_key_user(): + # revoke MUST go through revoke_key_user (KeyUser.revokedAt), not token + # revoke — token revoke is a no-op once redeemed (spirekeeper#22). + bunker = FakeBunker(revoke_count=2) + count = asyncio.run(revoke_spire(_machine(), admin_client=bunker)) + assert count == 2 + assert bunker.named("revoke_key_user") == [("revoke_key_user", "spire-m1")] + assert not bunker.named("revoke_token") # never token-revoke + + +def test_revoke_spire_maps_bunker_error(): + bunker = FakeBunker() + + async def _boom(key_name): + raise NsecBunkerError("nope") + + bunker.revoke_key_user = _boom + with pytest.raises(PairingError, match="revoke"): + asyncio.run(revoke_spire(_machine(), admin_client=bunker)) diff --git a/views_api.py b/views_api.py index a37df74..8dff07e 100644 --- a/views_api.py +++ b/views_api.py @@ -29,7 +29,13 @@ from .cassette_transport import ( publish_to_atm, ) from .fee_transport import publish_fee_config -from .pairing import PairResult, PairingError, pair_spire +from .pairing import ( + PairResult, + PairingError, + RevokeResult, + pair_spire, + revoke_spire, +) from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -63,6 +69,7 @@ from .crud import ( replace_commission_splits, reset_settlement_for_retry, set_machine_pairing, + set_machine_unpaired, update_cassette_config, update_dca_client, update_deposit, @@ -297,15 +304,21 @@ async def api_pair_machine( 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).""" + Re-pair is supported (re-issues a token for the same spire key). + `duration_hours` (optional) time-bounds the token; revoke via the + sibling `POST .../revoke` endpoint.""" 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) + result = await pair_spire( + machine, + relays=data.relays, + admin_client=client, + duration_hours=data.duration_hours, + ) except NsecBunkerNotConfiguredError as exc: raise HTTPException( HTTPStatus.SERVICE_UNAVAILABLE, @@ -327,6 +340,34 @@ async def api_pair_machine( return result +@spirekeeper_api_router.post( + "/api/v1/dca/machines/{machine_id}/revoke", response_model=RevokeResult +) +async def api_revoke_machine( + machine_id: str, + user: User = Depends(check_user_exists), +) -> RevokeResult: + """Revoke a spire's bunker access — the "Revoke spire access" UX + (#9/#12). Cuts the spire's signing ability at the bunker + (`KeyUser.revokedAt` via `revoke_key_user`; token-revoke alone is a + no-op once the token is redeemed — see #22), then marks the machine + unpaired. `revoked_count` >= 1 = access cut; 0 = nothing was bound.""" + machine = await _machine_owned_by(machine_id, user.id) + try: + async with NsecBunkerAdminClient.from_settings() as client: + revoked_count = await revoke_spire(machine, 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"revoke failed: {exc}") from exc + + await set_machine_unpaired(machine_id) + return RevokeResult(revoked_count=revoked_count) + + @spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) async def api_list_machines( user: User = Depends(check_user_exists), From a18f653ca7efef7a299429ec28cacc1134e7b8be Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 19:23:22 +0200 Subject: [PATCH 07/19] feat(ui): Fleet Pair / Revoke spire UI (#9/#12) Operator-facing front for POST /machines/{id}/pair + /revoke (#21/#23): - Pairing chip per machine row (paired / not-paired + paired-at tooltip). - 'Pair' (qr_code_2) opens a dialog -> relays + optional duration_hours -> POST /pair -> renders the seed_url as + copy, shows the bunker-minted spire npub. Re-pair relabels. - 'Revoke' (link_off, shown when paired) -> confirm -> POST /revoke -> updates the row, reports revoked_count (>=1 cut / 0 never-bound). - Row reflects the bunker-minted identity immediately (machine_npub <- spire_pubkey_hex, paired_at). Quasar-UMD conventions: explicit close tags, ${ } delimiters, :style. JS syntax-checked, conforms to .prettierrc; 210 backend tests unaffected. Needs a manual browser smoke (superuser-gated page). Co-Authored-By: Claude Opus 4.8 (1M context) --- static/js/index.js | 95 ++++++++++++++++++++++++++ templates/spirekeeper/index.html | 113 +++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/static/js/index.js b/static/js/index.js index f510e9c..52d3a3b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -191,6 +191,14 @@ window.app = Vue.createApp({ saving: false, data: {} }, + pairDialog: { + show: false, + saving: false, + machine: null, + relays: '', + durationHours: null, + result: null + }, machineDetail: { show: false, loading: false, @@ -781,6 +789,93 @@ window.app = Vue.createApp({ }) }, + // ----------------------------------------------------------------- + // Pair / revoke spire (S0 / #9, #12) + // ----------------------------------------------------------------- + openPairDialog(machine) { + this.pairDialog.machine = machine + this.pairDialog.relays = '' + this.pairDialog.durationHours = null + this.pairDialog.result = null + this.pairDialog.show = true + }, + + async submitPair() { + const relays = (this.pairDialog.relays || '') + .split(/[\s,]+/) + .map(s => s.trim()) + .filter(Boolean) + if (!relays.length) { + Quasar.Notify.create({ + type: 'negative', + message: 'At least one relay is required' + }) + return + } + const body = {relays} + if (this.pairDialog.durationHours) { + body.duration_hours = Number(this.pairDialog.durationHours) + } + this.pairDialog.saving = true + try { + const {data} = await LNbits.api.request( + 'POST', + `${MACHINES_PATH}/${this.pairDialog.machine.id}/pair`, + null, + body + ) + this.pairDialog.result = data + // The bunker-minted key becomes the machine identity; reflect it + + // the paired state in the row immediately. + const m = this.machines.find(x => x.id === this.pairDialog.machine.id) + if (m) { + m.machine_npub = data.spire_pubkey_hex + m.bunker_spire_key_name = data.bunker_key_name + m.paired_at = new Date().toISOString() + } + Quasar.Notify.create({ + type: 'positive', + message: 'Spire paired — hand the seed URL to the device' + }) + } catch (e) { + this._notifyError(e, 'Pairing failed') + } finally { + this.pairDialog.saving = false + } + }, + + confirmRevokeMachine(machine) { + Quasar.Dialog.create({ + title: 'Revoke spire access?', + message: + `This cuts ${machine.name || machine.machine_npub.slice(0, 12)}'s` + + ' signing access at the bunker — the spire can no longer submit' + + ' cash-outs until you re-pair it. Continue?', + html: true, + cancel: true, + persistent: true + }).onOk(async () => { + try { + const {data} = await LNbits.api.request( + 'POST', + `${MACHINES_PATH}/${machine.id}/revoke`, + null + ) + const m = this.machines.find(x => x.id === machine.id) + if (m) m.paired_at = null + Quasar.Notify.create({ + type: data.revoked_count >= 1 ? 'positive' : 'warning', + message: + data.revoked_count >= 1 + ? 'Spire access revoked' + : 'Nothing was bound (the spire never connected)' + }) + } catch (e) { + this._notifyError(e, 'Revoke failed') + } + }) + }, + // ----------------------------------------------------------------- // Machine detail dialog (P9b) // ----------------------------------------------------------------- diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index b58714e..7bdebd3 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -131,6 +131,17 @@
+ + paired + Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() } + + + not paired + Edit + + ${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' } + + + Revoke spire access + @@ -821,6 +843,97 @@ + + + + + + +
Pair spire
+ + +
+ + + +

+ Mints a dedicated signing key for + + inside the operator bunker and issues a one-shot seed URL. The + spire's key never touches its disk; its cash-outs route to this + machine's wallet. Re-pairing issues a fresh seed. +

+ + + + +
+ + + + + + + + + + Paired. Scan this on the spire at first boot, or paste the seed URL + into provision-atm. Shown once — copy it now. + + +
+ +
+ + + + + +
+ Spire identity: + + + Copy npub + +
+
+ + + +
+
+ From 22678dfb4fd7c25644dbf996ae81e7feb1d8d81e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 19:27:29 +0200 Subject: [PATCH 08/19] feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bitspire#52 consumer review (2026-06-18) enumerated the kinds the spire signs as its OWN identity and found NIP-42 relay AUTH (kind 22242) missing from SPIRE_POLICY_RULES — a silent bunker reject the moment a relay challenges with AUTH. It must be bunker-signed (AUTH proves control of spire_pubkey, which only the bunker holds; can't use the local client_nsec). Adds 22242. Records the confirmed set in the policy comment: live = 21000 + 30078 + 22242; CLINK 21001-21003 dormant but kept; nip04 unused (v1 path is dead code). New test locks the required-kinds contract so 22242 can't silently regress. Co-Authored-By: Claude Opus 4.8 (1M context) --- pairing.py | 17 +++++++++++------ tests/test_pairing.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/pairing.py b/pairing.py index b65b760..8629de9 100644 --- a/pairing.py +++ b/pairing.py @@ -57,20 +57,25 @@ 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) +# - 22242 NIP-42 relay AUTH — the spire authenticates to its relays +# (must be bunker-signed: AUTH proves control of spire_pubkey, +# which only the bunker holds; can't be done with client_nsec) +# - 21001-21003 CLINK Offer / Debit / Manage (dormant on dev; kept) # - 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. +# (rule.kind.toString()). Mirrors lnbits' DEFAULT_POLICY_* split. nip04 is +# deliberately absent — the v1/nip04 path is dead code (bitspire#52). # -# 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. +# Kind set confirmed against the spire's signing sites in bitspire#52 +# (2026-06-18): live = 21000 + 30078 + 22242; CLINK 21001-21003 dormant but +# kept; nip04 unused. Under-granting = silent bunker reject, so err toward +# inclusion (low blast radius — only widens what a spire signs as its OWN key). SPIRE_POLICY_NAME = "spirekeeper-spire" SPIRE_POLICY_RULES = [ {"method": "sign_event", "kind": 21000}, + {"method": "sign_event", "kind": 22242}, # NIP-42 relay AUTH (bitspire#52) {"method": "sign_event", "kind": 21001}, {"method": "sign_event", "kind": 21002}, {"method": "sign_event", "kind": 21003}, diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 4deb5a0..54f00ca 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -305,3 +305,14 @@ def test_revoke_spire_maps_bunker_error(): bunker.revoke_key_user = _boom with pytest.raises(PairingError, match="revoke"): asyncio.run(revoke_spire(_machine(), admin_client=bunker)) + + +def test_policy_authorizes_required_signing_kinds(): + # Kinds the spire signs as its OWN identity, confirmed against the + # consumer signing sites in bitspire#52 (2026-06-18). A missing kind is a + # silent bunker reject. 22242 = NIP-42 relay AUTH (must be bunker-signed — + # it proves control of spire_pubkey). nip04 stays out (v1 path is dead). + kinds = {r["kind"] for r in SPIRE_POLICY_RULES if r["method"] == "sign_event"} + assert {21000, 30078, 22242} <= kinds + assert "nip04_encrypt" not in SPIRE_POLICY_METHODS_NO_KIND + assert "nip04_decrypt" not in SPIRE_POLICY_METHODS_NO_KIND From 76090ab5da8d8fb959670158759663f8a9b6bc87 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:59:29 +0200 Subject: [PATCH 09/19] fix(fleet-ui): wrap pair-dialog steps in template v-if/v-else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pair dialog had two interleaved v-if/v-else sibling pairs (q-card-section + q-card-actions per step). Vue requires v-else to immediately follow its v-if sibling, so the second v-else (actions) trailed a v-else (section) — illegal, throwing compiler error 30 ("v-else has no adjacent v-if") and breaking the entire Vue mount. Wrap each step's section+actions in one