diff --git a/__init__.py b/__init__.py index 39de3d8..d40a411 100644 --- a/__init__.py +++ b/__init__.py @@ -4,6 +4,7 @@ from fastapi import APIRouter from lnbits.tasks import create_permanent_unique_task from loguru import logger +from .cashin_transport import register_create_withdraw_rpc from .crud import db from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices @@ -13,9 +14,7 @@ from .views_api import spirekeeper_api_router logger.info("spirekeeper v2 loaded") -spirekeeper_ext: APIRouter = APIRouter( - prefix="/spirekeeper", tags=["DCA Admin"] -) +spirekeeper_ext: APIRouter = APIRouter(prefix="/spirekeeper", tags=["DCA Admin"]) spirekeeper_ext.include_router(spirekeeper_generic_router) spirekeeper_ext.include_router(spirekeeper_api_router) @@ -57,6 +56,12 @@ def spirekeeper_start(): # wallet, not an auto-created machine wallet. Soft-fails on lnbits # versions that don't yet expose `register_roster_resolver`. register_roster_with_lnbits() + # Secure cash-in (#31): register the `create_withdraw` nostr-transport RPC + # so the ATM requests a server-priced, server-stamped cash-in withdraw link + # over the bunker-signed transport — amount/fee/attribution derived + # server-side, never client-supplied. Soft-fails if `register_rpc` isn't + # exposed by this lnbits. + register_create_withdraw_rpc() __all__ = [ diff --git a/bitspire.py b/bitspire.py index 9e0c70c..1609052 100644 --- a/bitspire.py +++ b/bitspire.py @@ -126,6 +126,14 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None: "missing nostr_sender_pubkey on Payment.extra — invoice was not " "issued through the nostr-transport path" ) + if not machine.machine_npub: + # Unpaired machine (machine_npub None — nullable since #29/m011). It has + # no identity to attribute a settlement to; reject cleanly rather than + # let normalize_public_key(None) raise an uncaught AttributeError. + raise SettlementAttributionError( + f"machine {machine.id} is unpaired (no machine_npub); " + "a settlement cannot be attributed to it" + ) from lnbits.utils.nostr import normalize_public_key try: diff --git a/cashin_transport.py b/cashin_transport.py new file mode 100644 index 0000000..c041f48 --- /dev/null +++ b/cashin_transport.py @@ -0,0 +1,135 @@ +""" +Secure cash-in: a `create_withdraw` nostr-transport RPC (aiolabs/spirekeeper#31). + +Mirrors withdraw's `lnurlw_create_link`, but cash-in-semantic. The ATM sends the +gross `principal_sats` (the hardware-attested fiat value) over the bunker-signed +kind-21000 transport; the operator side derives the fee, the NET withdraw +amount, and the attribution **server-side** — none of it client-supplied. The +customer claims the NET link; the payout carries the stamped `extra` +(aiolabs/withdraw#3) so `_handle_payment` records the `cash_in` settlement with +cryptographic attribution (the verified transport sender), exactly like the +cash-out path. + +Why this exists: `lnurlw_create_link` takes `min/max_withdrawable` and `extra` +straight from the client body, so an authenticated-but-malicious/buggy ATM could +set the gross amount (no fee), forge `nostr_sender_pubkey`, or request an +arbitrary amount. This RPC closes that by computing everything server-side. +""" + +from __future__ import annotations + +from loguru import logger + +from .crud import get_machine_by_atm_pubkey_hex, get_super_config + +_RPC_NAME = "create_withdraw" + + +async def handle_create_withdraw(auth, request) -> dict: + """nostr-transport RPC handler. `auth` is a WalletTypeInfo (the operator + wallet, roster-resolved from the verified sender); `request` is a + NostrRpcRequest with `body`, `sender_pubkey` (verified), and `event_id`.""" + # Import withdraw lazily so registration never hard-depends on the withdraw + # extension being importable at startup; a missing dep fails the request, + # not the daemon. + try: + from withdraw.crud import create_withdraw_link + from withdraw.helpers import create_lnurl_from_baseurl + from withdraw.models import CreateWithdrawData + except ImportError as exc: + raise ValueError( + "withdraw extension unavailable; cannot mint a cash-in link" + ) from exc + + body = request.body or {} + + principal_sats = body.get("principal_sats") + if not isinstance(principal_sats, int) or principal_sats <= 0: + raise ValueError("principal_sats must be a positive integer") + + # Attribution is the VERIFIED transport sender — never read it from the body. + sender = (request.sender_pubkey or "").lower() + if not sender: + raise ValueError("missing verified sender_pubkey") + + machine = await get_machine_by_atm_pubkey_hex(sender) + if machine is None: + raise ValueError("no active machine for this signer") + # Defence in depth: the roster already resolved `auth.wallet` from `sender`; + # confirm it's the machine's own wallet before minting a payout link on it. + if machine.wallet_id != auth.wallet.id: + raise ValueError("machine wallet does not match the authenticated wallet") + + super_config = await get_super_config() + if super_config is None: + raise ValueError("super_config not initialised") + + # Per-tx cap (server-side; the bunker ACL / usage caps cannot see sats). + cap = getattr(super_config, "max_cash_in_sats", None) + if cap is not None and principal_sats > cap: + raise ValueError( + f"principal_sats {principal_sats} exceeds max_cash_in_sats {cap}" + ) + + # Round each leg independently so the split matches parse_settlement exactly + # (platform_fee + operator_fee == fee_sats → fee_mismatch_sats = 0). + platform_fee = round( + principal_sats * float(super_config.super_cash_in_fee_fraction) + ) + operator_fee = round(principal_sats * float(machine.operator_cash_in_fee_fraction)) + fee_sats = platform_fee + operator_fee + net_sats = principal_sats - fee_sats + if net_sats <= 0: + raise ValueError("fee >= principal; nothing to withdraw") + + data = CreateWithdrawData( + title=body.get("title") or f"bitSpire Cash-In {machine.id}", + min_withdrawable=net_sats, + max_withdrawable=net_sats, + uses=1, + wait_time=int(body.get("wait_time") or 1), + is_unique=False, + extra={ + "source": "bitspire", + "type": "cash_in", + "principal_sats": principal_sats, + "fee_sats": fee_sats, + "nostr_sender_pubkey": sender, + "nostr_event_id": body.get("client_ref") or request.event_id, + }, + ) + link = await create_withdraw_link(data, auth.wallet.id) + lnurl = create_lnurl_from_baseurl(link) + logger.info( + f"spirekeeper: create_withdraw machine={machine.id} " + f"principal={principal_sats} fee={fee_sats} (super={platform_fee} " + f"op={operator_fee}) net={net_sats} link={link.id}" + ) + return { + "link_id": link.id, + # `Lnurl.__str__` is the raw URL — wallets need the bech32 LNURL1… + # (lud01). Mirror withdraw's `_populate_lnurl` field convention. + "lnurl": str(lnurl.bech32), + "lnurl_url": str(lnurl.url), + "net_sats": net_sats, + "principal_sats": principal_sats, + "fee_sats": fee_sats, + } + + +def register_create_withdraw_rpc() -> None: + """Register `create_withdraw` with the lnbits nostr transport. Soft-fails if + the transport doesn't expose `register_rpc` (older lnbits).""" + try: + from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore + AUTH_WALLET, + register_rpc, + ) + except ImportError: + logger.warning( + "spirekeeper: nostr-transport register_rpc unavailable; " + "'create_withdraw' not registered (secure cash-in over RPC disabled)" + ) + return + register_rpc(_RPC_NAME, handle_create_withdraw, AUTH_WALLET) + logger.info("spirekeeper: registered nostr-transport RPC 'create_withdraw'") diff --git a/cassette_transport.py b/cassette_transport.py index 9f60d9a..e6517a1 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -141,8 +141,10 @@ def _state_d_tag(atm_pubkey_hex: str) -> str: def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]: """Bootstrap-consumer subscription filter helper: returns the full - `#d=[...]` list for all known ATMs an operator subscribes to.""" - return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] + `#d=[...]` list for all known PAIRED ATMs an operator subscribes to. + Unpaired machines (machine_npub is None — nullable since #29/m011) have no + state-beacon d-tag yet, so skip them rather than crash `_atm_hex_pubkey`.""" + return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines if m.machine_npub] # ============================================================================= diff --git a/crud.py b/crud.py index 9646f8d..6eda100 100644 --- a/crud.py +++ b/crud.py @@ -180,6 +180,11 @@ async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None: target = atm_pubkey_hex.lower() machines = await list_all_active_machines() for m in machines: + # Unpaired machines (machine_npub is None — nullable since #29/m011) + # have no identity to match and would raise AttributeError in + # normalize_public_key (not caught below); skip them. + if not m.machine_npub: + continue try: if normalize_public_key(m.machine_npub).lower() == target: return m @@ -202,6 +207,55 @@ 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 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/migrations.py b/migrations.py index b8e6ec0..6ede0eb 100644 --- a/migrations.py +++ b/migrations.py @@ -82,7 +82,7 @@ async def m001_satmachine_v2_initial(db): CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines ( id TEXT PRIMARY KEY, operator_user_id TEXT NOT NULL, - machine_npub TEXT NOT NULL UNIQUE, + machine_npub TEXT UNIQUE, wallet_id TEXT NOT NULL, name TEXT, location TEXT, @@ -735,3 +735,128 @@ 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}" + ) + + +async def m011_machine_npub_nullable(db): + """Make dca_machines.machine_npub nullable so an operator can register a + machine *unpaired* (no npub) and have its identity minted by the bunker + at pairing time (model A1, aiolabs/spirekeeper#9). The npub is only + supplied up front on the development self-key path (a machine that holds + its own signing key). UNIQUE stays — NULLs don't collide, so any number + of unpaired machines coexist. + + Pre-public-launch: no back-compat shim. Existing rows are preserved by + the rebuild; the column simply loses NOT NULL. + """ + if db.type != "SQLITE": + # Postgres / Cockroach can drop the constraint in place. + await db.execute( + "ALTER TABLE spirekeeper.dca_machines " + "ALTER COLUMN machine_npub DROP NOT NULL" + ) + return + + # SQLite can't drop NOT NULL in place — rebuild the table (same pattern + # as m008/m009), preserving every row + the indexes. + await db.execute( + f""" + CREATE TABLE spirekeeper.dca_machines_new ( + id TEXT PRIMARY KEY, + operator_user_id TEXT NOT NULL, + machine_npub TEXT UNIQUE, + wallet_id TEXT NOT NULL, + name TEXT, + location TEXT, + fiat_code TEXT NOT NULL DEFAULT 'GTQ', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + operator_cash_in_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, + operator_cash_out_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, + bunker_spire_key_name TEXT, + paired_at TIMESTAMP + ) + """ + ) + await db.execute( + """ + INSERT INTO spirekeeper.dca_machines_new + (id, operator_user_id, machine_npub, wallet_id, name, location, + fiat_code, is_active, created_at, updated_at, + operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, + bunker_spire_key_name, paired_at) + SELECT id, operator_user_id, machine_npub, wallet_id, name, location, + fiat_code, is_active, created_at, updated_at, + operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, + bunker_spire_key_name, paired_at + FROM spirekeeper.dca_machines + """ + ) + await db.execute("DROP TABLE spirekeeper.dca_machines") + await db.execute( + "ALTER TABLE spirekeeper.dca_machines_new RENAME TO dca_machines" + ) + await db.execute( + "CREATE INDEX IF NOT EXISTS dca_machines_operator_idx " + "ON dca_machines (operator_user_id)" + ) + await db.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq " + "ON dca_machines (wallet_id)" + ) + + +async def m012_add_max_cash_in_sats(db): + """Server-side per-transaction cash-in ceiling (aiolabs/spirekeeper#31). + + The secure `create_withdraw` RPC derives fee/net/attribution server-side, + but `principal_sats` is necessarily ATM-attested (only the hardware knows + how much cash went in). The bunker ACL / usage caps gate call *rate*, not + *sats*, so a single in-rate call could request an arbitrarily large payout. + `max_cash_in_sats` bounds that: the handler rejects a cash-in whose + principal exceeds it. NULL = no cap. + """ + await db.execute( + "ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER" + ) diff --git a/models.py b/models.py index c158fba..99ee552 100644 --- a/models.py +++ b/models.py @@ -28,7 +28,11 @@ class CreateMachineData(BaseModel): not against any fee total. See aiolabs/satmachineadmin#37 / #38. """ - machine_npub: str + # Optional: blank = register the machine UNPAIRED — the bunker mints its + # identity at pairing (model A1, the normal path). Supplying an npub here + # is the development self-key path (a machine that holds its own signing + # key); see views_api.api_create_machine. + machine_npub: str | None = None wallet_id: str name: str | None = None location: str | None = None @@ -48,7 +52,7 @@ class CreateMachineData(BaseModel): class Machine(BaseModel): id: str operator_user_id: str - machine_npub: str + machine_npub: str | None # NULL until paired (or supplied on the dev self-key path) wallet_id: str name: str | None location: str | None @@ -56,6 +60,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 @@ -78,6 +85,29 @@ 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. `bunker_relay` overrides the relay embedded in the + seed's `bunker://` URL (the relay the spire uses to *reach* the bunker); + when omitted it defaults to `settings.lnbits_nsec_bunker_url`. Set it when + the relay lnbits uses to reach the bunker differs from the one the spire + must reach — e.g. an internal docker hostname (`ws://lnbits:5001/…`) vs a + LAN/public URL (`ws://192.168.0.32:5001/…`), or any split-relay deploy. + `duration_hours` optionally time-bounds the spire's connect token + (None = non-expiring).""" + + relays: list[str] + bunker_relay: str | None = None + 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 + + # ============================================================================= # DCA Clients — LP registrations, scoped per (machine, user). # ============================================================================= @@ -452,6 +482,10 @@ class SuperConfig(BaseModel): super_cash_in_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None + # Per-transaction cash-in ceiling in sats (#31). The bunker ACL gates call + # rate, not sats, so this bounds a single ATM-attested principal. NULL = no + # cap. + max_cash_in_sats: int | None = None updated_at: datetime @@ -459,6 +493,7 @@ class UpdateSuperConfigData(BaseModel): super_cash_in_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None + max_cash_in_sats: int | None = None @validator( "super_cash_in_fee_fraction", @@ -471,6 +506,14 @@ class UpdateSuperConfigData(BaseModel): raise ValueError("super fee fraction must be between 0 and 1") return round(float(v), 4) + @validator("max_cash_in_sats") + def _cap_non_negative(cls, v): + if v is None: + return v + if v < 0: + raise ValueError("max_cash_in_sats must be >= 0") + return int(v) + # ============================================================================= # Operator UX action carriers — partial-tx and balance-settlement features. diff --git a/pairing.py b/pairing.py new file mode 100644 index 0000000..c2d923f --- /dev/null +++ b/pairing.py @@ -0,0 +1,277 @@ +"""Seed-URL pairing for bitSpire machines (S0 / aiolabs/spirekeeper#9), model A1. + +Mints a per-spire signing key *inside the operator's nsecbunkerd*, issues a +scoped NIP-46 connect token, and builds the one-shot **seed URL** the spire +redeems at first boot. The spire then self-signs all of its own events +(kind-21000 cash RPC, kind-30078 beacon + cassette-state, CLINK 21001-21003) +as that bunker-held key; lnbits' path-B roster (`nostr_transport/roster.py`) +maps the spire npub to the operator's wallet. No nsec ever lands on the +spire's disk. + +Division of labour (vs. lnbits' `RemoteBunkerSigner.provision`, which is the +reference for the admin chain): + + spirekeeper (here) spire, at first boot (bitspire#52) + ────────────────── ────────────────────────────────── + 1. create_new_key 5. NIP-46 connect — redeem the token with a + 2. ensure_policy freshly-generated *client* keypair; bunker + 3. create_new_token binds (client_pubkey → spire key). The + 4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the + (package token in URL) signing key never leaves the bunker. + +We deliberately do NOT run the connect/eager-bind step here: the spire is the +NIP-46 client, so the binding must happen spire-side with the spire's own +client keypair. spirekeeper only mints + packages. + +Seed URL wire format (contract shared with bitspire#52): + + spire-seed:v1: json = { + "v": 1, + "spire_npub": "npub1…", # the bunker-minted spire identity + "spire_pubkey": "<64-hex>", # same key, hex (consumer convenience) + "bunker_url": "bunker://?relay=&secret=", + "relays": ["wss://…"], # relays for the spire's own events + } +""" + +from __future__ import annotations + +import base64 +import json +from urllib.parse import quote + +from lnbits.core.services.nsec_bunker import ( + NsecBunkerAdminClient, + NsecBunkerError, + NsecBunkerNotConfiguredError, + npub_to_hex, +) +from lnbits.core.signers.remote_bunker import ensure_policy +from lnbits.settings import settings +from pydantic import BaseModel + +from .models import Machine + +SEED_URL_SCHEME = "spire-seed:v1:" + +# Policy granted to every spire's connect token. Scoped to exactly what a +# bitSpire signs as itself: +# - 21000 nostr-transport cash RPC envelope to lnbits +# - 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. nip04 is +# deliberately absent — the v1/nip04 path is dead code (bitspire#52). +# +# 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}, + {"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 + + +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 + is replace-by-name on the bunker side).""" + return f"spire-{machine_id}" + + +def _recover_token(tokens: list[dict], client_name: str) -> str: + """Pull the freshly-issued `#` token out of the bunker's + `get_key_tokens` response. Match by client name when the bunker + serializes it; otherwise fall back to the most-recent entry (same + defensiveness as lnbits' provision()).""" + matching = [ + t + for t in tokens + if t.get("clientName") == client_name or t.get("client_name") == client_name + ] or tokens + if not matching: + raise PairingError("bunker returned no tokens after create_new_token") + token = matching[-1].get("token") + if not isinstance(token, str) or "#" not in token: + raise PairingError(f"bunker returned a malformed token: {token!r}") + return token + + +def build_seed_url( + *, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] +) -> str: + payload = { + "v": 1, + "spire_npub": spire_npub, + "spire_pubkey": spire_pubkey_hex, + "bunker_url": bunker_url, + "relays": relays, + } + blob = ( + base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) + .decode() + .rstrip("=") + ) + return SEED_URL_SCHEME + blob + + +async def pair_spire( + machine: Machine, + *, + relays: list[str], + admin_client: NsecBunkerAdminClient, + bunker_relay: str | None = None, + keystore_passphrase: str | None = None, + 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) stamps `expiresAt` + on the spire's connect token, bounding the established binding's lifetime. + Since aiolabs/nsecbunkerd#27 (deployed 2026-06-19) the sign-time ACL + evaluates token lifecycle on EVERY request (`checkIfPubkeyAllowed` step 4 + joins through a `liveWhere` filter; `applyToken` no longer photocopies + grants), so an expired token stops signing post-bind, not just at connect. + The spire must re-pair to keep signing once the token lapses. None = + non-expiring (the only invalidation path is then `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 + with a fake client. + + `relays` are the relays the spire uses for its *own* events + (kind-21000/30078) — typically the operator's public nostrrelay; supplied by + the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the + spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` + defaults to the lnbits bunker setting. Both injectable for tests. + + Raises PairingError on any bunker failure; no state is persisted here + (the API layer persists on success). + """ + passphrase = ( + keystore_passphrase + if keystore_passphrase is not None + else settings.lnbits_nsec_bunker_keystore_passphrase + ) + 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") + # The relay baked into `bunker_url` is where the *spire* (the remote ATM) + # reaches the bunker, so it must be a machine-reachable public URL — NOT + # `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits + # reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM — + # the localhost-relay /pair gotcha bitspire flagged). Default to the spire's + # own event relay (the bunker lives on the same operator relay the spire + # publishes to); an explicit `bunker_relay` overrides for split-relay deploys. + relay = bunker_relay if bunker_relay else relays[0] + + key_name = spire_key_name(machine.id) + client_name = f"spire-client-{machine.id}" + + try: + spire_npub = await admin_client.create_new_key(key_name, passphrase) + spire_pubkey_hex = npub_to_hex(spire_npub) + policy_id = await ensure_policy( + admin_client, + name=SPIRE_POLICY_NAME, + rules=SPIRE_POLICY_RULES, + methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, + ) + await admin_client.create_new_token( + key_name, client_name, policy_id, 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 + 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, + ) + + +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). + + Calls `revoke_key_user` (sets `KeyUser.revokedAt`) — the subject-level + sticky ban that's checked at step 2 of `checkIfPubkeyAllowed`, beating + every grant. This cuts the WHOLE binding regardless of how many tokens + were issued to the spire, which is the right semantics for "revoke this + spire." (Since aiolabs/nsecbunkerd#27 token-revoke also works post-bind — + the sign-time ACL now evaluates `Token.revokedAt`/`expiresAt` live every + request, closing the #22 no-op — but per-token revoke only cuts one + token's grant, so `revoke_key_user` remains the correct full-deauth call.) + + 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/static/image/aio.png b/static/image/aio.png index db8823b..63dc24b 100644 Binary files a/static/image/aio.png and b/static/image/aio.png differ diff --git a/static/js/index.js b/static/js/index.js index f510e9c..053133d 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -103,7 +103,8 @@ window.app = Vue.createApp({ data: { super_cash_in_fee_fraction: 0, super_cash_out_fee_fraction: 0, - super_fee_wallet_id: '' + super_fee_wallet_id: '', + max_cash_in_sats: null } }, @@ -191,6 +192,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, @@ -568,13 +577,50 @@ window.app = Vue.createApp({ this.superConfig?.super_cash_in_fee_fraction ?? 0, super_cash_out_fee_fraction: this.superConfig?.super_cash_out_fee_fraction ?? 0, - super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' + super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '', + max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null } this.superFeeDialog.show = true }, + // Guard the decimal-vs-percent trap shared by the super + operator fee + // forms: fees are decimal fractions (3% = 0.03), capped at 0.15. A value + // > 0.15 almost always means a percent was typed (3 instead of 0.03). + // Returns false + shows a clear toast so the operator never sees a raw 400. + _assertFeesDecimal(...fracs) { + if (fracs.some((v) => !Number.isFinite(v) || v < 0 || v > 0.15)) { + Quasar.Notify.create({ + type: 'negative', + message: 'Enter each fee as a decimal fraction (e.g. 3% = 0.03)', + caption: + 'Range 0–0.15. A value above 0.15 usually means a percent was typed (3 instead of 0.03).' + }) + return false + } + return true + }, + async submitSuperFee() { const d = this.superFeeDialog.data + if (!this._assertFeesDecimal( + Number(d.super_cash_in_fee_fraction), + Number(d.super_cash_out_fee_fraction) + )) return + // Blank cap field -> null (the PUT skips null, so the existing value is + // preserved rather than cleared — set 0 to reject every cash-in). + const cap = + d.max_cash_in_sats === '' || + d.max_cash_in_sats === null || + d.max_cash_in_sats === undefined + ? null + : Number(d.max_cash_in_sats) + if (cap !== null && (!Number.isInteger(cap) || cap < 0)) { + Quasar.Notify.create({ + type: 'negative', + message: 'Max cash-in must be a non-negative whole number of sats' + }) + return + } this.superFeeDialog.saving = true try { const {data} = await LNbits.api.request( @@ -582,7 +628,8 @@ window.app = Vue.createApp({ { super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), - super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null + super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null, + max_cash_in_sats: cap } ) this.superConfig = data @@ -691,13 +738,17 @@ window.app = Vue.createApp({ async submitAddMachine() { const body = this._cleanMachineForm(this.addMachineDialog.data) - if (!body.machine_npub || !body.wallet_id) { + if (!body.wallet_id) { Quasar.Notify.create({ type: 'negative', - message: 'machine_npub and wallet_id are required' + message: 'A wallet is required' }) return } + if (!this._assertFeesDecimal( + Number(body.operator_cash_in_fee_fraction) || 0, + Number(body.operator_cash_out_fee_fraction) || 0 + )) return this.addMachineDialog.saving = true try { const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body) @@ -705,7 +756,7 @@ window.app = Vue.createApp({ this.addMachineDialog.show = false Quasar.Notify.create({ type: 'positive', - message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added` + message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added` }) } catch (e) { this._notifyError(e, 'Failed to add machine') @@ -733,6 +784,10 @@ window.app = Vue.createApp({ async submitEditMachine() { const d = this.editMachineDialog.data + if (!this._assertFeesDecimal( + Number(d.operator_cash_in_fee_fraction) || 0, + Number(d.operator_cash_out_fee_fraction) || 0 + )) return this.editMachineDialog.saving = true try { const {data} = await LNbits.api.request( @@ -764,7 +819,7 @@ window.app = Vue.createApp({ Quasar.Dialog.create({ title: 'Delete machine?', message: - `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` + + `This removes ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}` + ' from your fleet. Existing settlements and payment history are preserved' + ' — only the machine row itself is removed. Continue?', html: true, @@ -781,6 +836,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 || 'unpaired').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) // ----------------------------------------------------------------- @@ -1420,7 +1562,7 @@ window.app = Vue.createApp({ // Helpers // ----------------------------------------------------------------- shortNpub(npub) { - if (!npub) return '' + if (!npub) return 'unpaired' if (npub.length <= 16) return npub return npub.slice(0, 8) + '…' + npub.slice(-6) }, @@ -1506,7 +1648,7 @@ window.app = Vue.createApp({ _cleanMachineForm(d) { return { - machine_npub: (d.machine_npub || '').trim(), + machine_npub: (d.machine_npub || '').trim() || null, wallet_id: d.wallet_id, name: (d.name || '').trim() || null, location: (d.location || '').trim() || null, diff --git a/tasks.py b/tasks.py index 4685c84..e3865d2 100644 --- a/tasks.py +++ b/tasks.py @@ -130,7 +130,11 @@ async def _handle_payment(payment: Payment) -> None: data = parse_settlement( machine=machine, payment_hash=payment.payment_hash, - wire_sats=payment.sat, + # `payment.sat` is signed by protocol direction (negative for an + # outbound cash-in payout, positive for an inbound cash-out + # receipt). The settlement's `wire_sats` is a magnitude — direction + # is carried separately by `tx_type` — so pass the absolute value. + wire_sats=abs(payment.sat), extra=extra, super_config=super_config, ) @@ -205,7 +209,8 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment.payment_hash, - wire_sats=payment.sat, + # Magnitude, not the signed `payment.sat` (negative for outbound). + wire_sats=abs(payment.sat), fiat_amount=0.0, fiat_code=machine.fiat_code, exchange_rate=0.0, @@ -213,11 +218,11 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - fee_sats=0, platform_fee_sats=0, operator_fee_sats=0, - # tx_type is unknown for rejection paths; default to cash_out - # (the only direction currently wired). When S8 lands the - # listener will branch on tx_type from extra, and this default - # gets revisited. - tx_type="cash_out", + # The parsed tx_type is unavailable on the rejection path, but the + # authenticated protocol direction is: an outbound payment is a + # cash-in, an inbound one a cash-out. Use that so a rejected row shows + # the right direction instead of always reading "cash-out". + tx_type="cash_in" if not payment.is_in else "cash_out", ) rejected = await create_settlement_idempotent( data, initial_status="rejected", error_message=str(exc) @@ -230,7 +235,10 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - return logger.error( f"spirekeeper: rejected settlement {rejected.id} " - f"(machine={machine.machine_npub[:12]}..., " + # An unpaired machine (machine_npub None) reaches here now that + # assert_nostr_attribution rejects it — fall back to the id so the + # log line doesn't crash on None[:12]. + f"(machine={(machine.machine_npub or machine.id)[:12]}..., " f"payment_hash={payment.payment_hash[:12]}...): {exc}" ) diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index b58714e..01bb973 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 + @@ -770,13 +792,13 @@ + + + + + + +
Pair spire
+ + +
+ + + + + + +
+
+ @@ -1251,12 +1368,12 @@ typically a wallet you (the super) own.

@@ -1264,6 +1381,11 @@ label="Super fee destination wallet_id" hint="LNbits wallet that collects the platform fee" class="q-mb-md" dense outlined> + @@ -1510,12 +1632,12 @@ diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py new file mode 100644 index 0000000..0d50d95 --- /dev/null +++ b/tests/test_pair_endpoint.py @@ -0,0 +1,167 @@ +"""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, duration_hours=None): + 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 + + +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 new file mode 100644 index 0000000..e3d2999 --- /dev/null +++ b/tests/test_pairing.py @@ -0,0 +1,332 @@ +"""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.core.services.nsec_bunker import NsecBunkerError +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, + revoke_spire, + spire_key_name, +) + +_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) +_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" +_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) +_RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"] +_BUNKER_RELAY = "wss://bunker.internal/relay" +_PASSPHRASE = "keystore-pass" # pragma: allowlist secret + + +@pytest.fixture(autouse=True) +def _clear_policy_cache(): + # lnbits' ensure_policy caches resolved policy ids on + # (admin_pubkey, name); clear between tests so each FakeBunker's + # canned policy state is honoured rather than a stale cached id. + from lnbits.core.signers import remote_bunker + + remote_bunker._POLICY_ID_CACHE.clear() + yield + + +def _machine(mid: str = "m1") -> Machine: + return Machine( + id=mid, + operator_user_id="op1", + machine_npub="placeholder", + wallet_id="w1", + name="sintra", + location=None, + fiat_code="EUR", + is_active=True, + created_at=_NOW, + updated_at=_NOW, + ) + + +class FakeBunker: + """Records calls; returns canned bunker responses.""" + + admin_pubkey = "fake-admin-pubkey" + + # pragma: allowlist secret + def __init__(self, *, policies=None, token_secret="s3cr3t", 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 + + 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, 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)) + 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_bunker_relay_defaults_to_spire_event_relay(): + """No explicit bunker_relay -> the relay baked into bunker_url is the spire's + own public event relay (relays[0]), NOT lnbits's internal bunker URL. This + is the localhost-relay /pair gotcha: a UI-minted seed (the form has no + bunker_relay field) must embed a machine-reachable relay, not ws://127.0.0.1. + An empty bunker_relay falls back to the same default.""" + from urllib.parse import quote + + for empty in (None, ""): + result = asyncio.run( + pair_spire( + _machine(), + relays=_RELAYS, + admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret + bunker_relay=empty, + keystore_passphrase=_PASSPHRASE, + ) + ) + assert f"relay={quote(_RELAYS[0], safe='')}" in result.bunker_url + assert "127.0.0.1" not in result.bunker_url + + +def test_missing_relay_or_passphrase_raises(): + 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 + + +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 goes through revoke_key_user (KeyUser.revokedAt) — the subject- + # level ban that cuts the whole binding, not just one token's grant. + # (Token-revoke also works post-bind since nsecbunkerd#27, but only + # severs a single token; revoke_key_user is the full-deauth call.) + 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)) + + +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 diff --git a/tests/test_unpaired_machine_guards.py b/tests/test_unpaired_machine_guards.py new file mode 100644 index 0000000..78ae196 --- /dev/null +++ b/tests/test_unpaired_machine_guards.py @@ -0,0 +1,57 @@ +""" +Regression: `machine_npub` is nullable (#29/m011 register-unpaired flow), so +every consumer that derives a Nostr identity from it must handle `None` rather +than crash `normalize_public_key(None)` (AttributeError: 'NoneType' has no +'startswith') or `machine_npub[:12]` (TypeError). See PR #33 — an unpaired +machine on the demo broke the platform-fee update (500) and the cassette +consumer. + +These cover the pure-function guards; the DB-backed loops +(get_machine_by_atm_pubkey_hex, the super-config republish loop) are exercised +on the dev stack with an unpaired active machine. +""" + +from datetime import datetime, timezone + +import pytest + +from ..bitspire import SettlementAttributionError, assert_nostr_attribution +from ..cassette_transport import build_state_d_tags_for_machines +from ..models import Machine + +_PAIRED_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9" + + +def _machine(npub: str | None) -> Machine: + now = datetime.now(timezone.utc) + return Machine( + id="unpaired1", + operator_user_id="op1", + machine_npub=npub, + wallet_id="w1", + name="unpaired", + location=None, + fiat_code="EUR", + is_active=True, + created_at=now, + updated_at=now, + ) + + +def test_attribution_rejects_unpaired_machine_cleanly(): + """An unpaired machine must raise the domain SettlementAttributionError + (which the listener records as 'rejected'), not an uncaught AttributeError + from normalize_public_key(None).""" + with pytest.raises(SettlementAttributionError): + assert_nostr_attribution( + _machine(None), + {"source": "bitspire", "nostr_sender_pubkey": _PAIRED_HEX}, + ) + + +def test_cassette_d_tags_skip_unpaired_machine(): + """build_state_d_tags_for_machines must skip unpaired machines rather than + crash _atm_hex_pubkey on a None npub — the cassette-consumer loop crash.""" + tags = build_state_d_tags_for_machines([_machine(_PAIRED_HEX), _machine(None)]) + assert len(tags) == 1 # only the paired machine contributes a d-tag + assert all("None" not in t for t in tags) diff --git a/views_api.py b/views_api.py index 079794d..35d35b2 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,13 @@ from .cassette_transport import ( publish_to_atm, ) from .fee_transport import publish_fee_config +from .pairing import ( + PairResult, + PairingError, + RevokeResult, + pair_spire, + revoke_spire, +) from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -55,6 +68,8 @@ from .crud import ( lp_is_onboarded, replace_commission_splits, reset_settlement_for_retry, + set_machine_pairing, + set_machine_unpaired, update_cassette_config, update_dca_client, update_deposit, @@ -80,6 +95,7 @@ from .models import ( DcaPayment, DcaSettlement, Machine, + PairMachineData, PartialDispenseData, PublishCassettesPayload, SetCommissionSplitsData, @@ -232,7 +248,7 @@ async def _assert_super_config_cap_safe( HTTPStatus.BAD_REQUEST, ( f"super cash-in fee {effective_in:.4f} would exceed cap " - f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): " f"+ operator {op_in:.4f} = " f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -242,7 +258,7 @@ async def _assert_super_config_cap_safe( HTTPStatus.BAD_REQUEST, ( f"super cash-out fee {effective_out:.4f} would exceed cap " - f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): " f"+ operator {op_out:.4f} = " f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -259,7 +275,13 @@ async def api_create_machine( data: CreateMachineData, user: User = Depends(check_user_exists) ) -> Machine: await _assert_wallet_owned_by(data.wallet_id, user.id) - await _assert_no_pubkey_collision(data.machine_npub) + # machine_npub is optional: blank = register UNPAIRED — the bunker mints + # the identity at pairing (the normal path). An npub supplied up front is + # the development self-key path; only then do we collision-check + publish + # a fee config now (an unpaired machine has no target yet, so it gets its + # config at pairing instead — see api_pair_machine). + if data.machine_npub: + await _assert_no_pubkey_collision(data.machine_npub) await _assert_machine_fee_cap_safe( data.operator_cash_in_fee_fraction, data.operator_cash_out_fee_fraction, @@ -268,10 +290,98 @@ async def api_create_machine( # Layer 2 (#39): publish initial fee config to the ATM so it can # unblock past its `awaiting-fees` maintenance gate. Soft-fails on # transport errors — machine creation has already succeeded. + if machine.machine_npub: + super_config = await get_super_config() + if super_config is not None: + await publish_fee_config(machine, super_config, user.id) + 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). + `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, + bunker_relay=data.bunker_relay, + duration_hours=data.duration_hours, + ) + 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), + ) + # Now that the machine has a bunker identity, publish its fee config so + # the spire can clear its `awaiting-fees` gate. For a machine created + # unpaired, this is the first time it has a target. Soft-fails (mirrors + # create); pairing has already succeeded. super_config = await get_super_config() if super_config is not None: - await publish_fee_config(machine, super_config, user.id) - return machine + paired = await _machine_owned_by(machine_id, user.id) + await publish_fee_config(paired, super_config, user.id) + 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]) @@ -971,6 +1081,12 @@ async def api_update_super_config( ) if super_fractions_changed: for machine in await list_all_active_machines(): + # Unpaired machines (machine_npub is None — nullable since #29/m011) + # have no Nostr identity to publish a fee-config beacon to. Skip + # them; they pick up the current fee config when they pair + # (api_pair_machine publishes on success). + if not machine.machine_npub: + continue await publish_fee_config(machine, config, machine.operator_user_id) return config @@ -1037,6 +1153,14 @@ async def api_publish_machine_cassettes( 500 — anything else from the publish path """ machine = await _machine_owned_by(machine_id, user.id) + if not machine.machine_npub: + # Unpaired machine (machine_npub None — nullable since #29/m011) has no + # ATM identity to publish a cassette config to. Fail fast with a clean + # 400 instead of crashing publish_to_atm's normalize_public_key(None). + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "machine is not paired — pair it before publishing cassette config", + ) existing = await list_cassette_configs_for_machine(machine_id) existing_positions = {row.position for row in existing}