Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main (push) Failing after 0s
Some checks failed
ci.yml / Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main (push) Failing after 0s
Reviewed-on: #21
This commit is contained in:
commit
32959d1533
7 changed files with 735 additions and 0 deletions
31
crud.py
31
crud.py
|
|
@ -202,6 +202,37 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
|
||||||
return await get_machine(machine_id)
|
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:
|
async def delete_machine(machine_id: str) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
|
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
|
||||||
|
|
|
||||||
|
|
@ -735,3 +735,44 @@ async def m009_split_fee_fractions_by_direction(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction"
|
"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-<machine_id>`). 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}"
|
||||||
|
)
|
||||||
|
|
|
||||||
12
models.py
12
models.py
|
|
@ -56,6 +56,9 @@ class Machine(BaseModel):
|
||||||
is_active: bool
|
is_active: bool
|
||||||
operator_cash_in_fee_fraction: float = 0.0
|
operator_cash_in_fee_fraction: float = 0.0
|
||||||
operator_cash_out_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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
@ -78,6 +81,15 @@ class UpdateMachineData(BaseModel):
|
||||||
return round(float(v), 4)
|
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).
|
# DCA Clients — LP registrations, scoped per (machine, user).
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
225
pairing.py
Normal file
225
pairing.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""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:<base64url(json)> json = {
|
||||||
|
"v": 1,
|
||||||
|
"spire_npub": "npub1…", # the bunker-minted spire identity
|
||||||
|
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience)
|
||||||
|
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>",
|
||||||
|
"relays": ["wss://…"], # relays for the spire's own events
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from lnbits.core.services.nsec_bunker import (
|
||||||
|
NsecBunkerAdminClient,
|
||||||
|
NsecBunkerError,
|
||||||
|
NsecBunkerNotConfiguredError,
|
||||||
|
npub_to_hex,
|
||||||
|
)
|
||||||
|
from lnbits.core.signers.remote_bunker import ensure_policy
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .models import Machine
|
||||||
|
|
||||||
|
SEED_URL_SCHEME = "spire-seed:v1:"
|
||||||
|
|
||||||
|
# Policy granted to every spire's connect token. Scoped to exactly what a
|
||||||
|
# bitSpire signs as itself:
|
||||||
|
# - 21000 nostr-transport cash RPC envelope to lnbits
|
||||||
|
# - 21001-21003 CLINK Offer / Debit / Manage (payment flow)
|
||||||
|
# - 30078 NIP-78 beacon + bitspire-cassettes-state hello-event
|
||||||
|
# Kind-scoped rules go in create_new_policy; kind-less methods (nip44, for
|
||||||
|
# encrypting cassette-state to the operator) are added via add_policy_rule
|
||||||
|
# because nsecbunkerd's create_new_policy chokes on null `kind`
|
||||||
|
# (rule.kind.toString()). Mirrors lnbits' DEFAULT_POLICY_* split.
|
||||||
|
#
|
||||||
|
# NOTE (reconcile when bitspire#52 lands): confirm this kind set against the
|
||||||
|
# spire's actual createSignedEvent / finalizeEvent call sites. Over-granting
|
||||||
|
# here only widens what a spire may sign *as its own key* — low blast radius —
|
||||||
|
# but under-granting makes the bunker reject the spire's events.
|
||||||
|
SPIRE_POLICY_NAME = "spirekeeper-spire"
|
||||||
|
SPIRE_POLICY_RULES = [
|
||||||
|
{"method": "sign_event", "kind": 21000},
|
||||||
|
{"method": "sign_event", "kind": 21001},
|
||||||
|
{"method": "sign_event", "kind": 21002},
|
||||||
|
{"method": "sign_event", "kind": 21003},
|
||||||
|
{"method": "sign_event", "kind": 30078},
|
||||||
|
]
|
||||||
|
SPIRE_POLICY_METHODS_NO_KIND = ["nip44_encrypt", "nip44_decrypt"]
|
||||||
|
|
||||||
|
|
||||||
|
class PairingError(Exception):
|
||||||
|
"""Pairing could not be completed (bunker unreachable, misconfigured,
|
||||||
|
or returned an unusable response). The caller maps this to a 4xx/5xx;
|
||||||
|
no machine state is mutated on failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class PairResult(BaseModel):
|
||||||
|
"""Output of a successful pair. The API layer persists
|
||||||
|
`bunker_spire_key_name` + `spire_npub` (→ machine_npub) + `paired_at`,
|
||||||
|
and returns `seed_url` to the operator (QR + copy)."""
|
||||||
|
|
||||||
|
spire_npub: str
|
||||||
|
spire_pubkey_hex: str
|
||||||
|
bunker_key_name: str
|
||||||
|
bunker_url: str
|
||||||
|
seed_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def spire_key_name(machine_id: str) -> str:
|
||||||
|
"""The spire's key name in the bunker keystore. Stable across re-pairs
|
||||||
|
so re-issuing a token reuses the same underlying key (create_new_key
|
||||||
|
is replace-by-name on the bunker side)."""
|
||||||
|
return f"spire-{machine_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_token(tokens: list[dict], client_name: str) -> str:
|
||||||
|
"""Pull the freshly-issued `<npub>#<secret>` token out of the bunker's
|
||||||
|
`get_key_tokens` response. Match by client name when the bunker
|
||||||
|
serializes it; otherwise fall back to the most-recent entry (same
|
||||||
|
defensiveness as lnbits' provision())."""
|
||||||
|
matching = [
|
||||||
|
t
|
||||||
|
for t in tokens
|
||||||
|
if t.get("clientName") == client_name or t.get("client_name") == client_name
|
||||||
|
] or tokens
|
||||||
|
if not matching:
|
||||||
|
raise PairingError("bunker returned no tokens after create_new_token")
|
||||||
|
token = matching[-1].get("token")
|
||||||
|
if not isinstance(token, str) or "#" not in token:
|
||||||
|
raise PairingError(f"bunker returned a malformed token: {token!r}")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def build_seed_url(
|
||||||
|
*, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str]
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"v": 1,
|
||||||
|
"spire_npub": spire_npub,
|
||||||
|
"spire_pubkey": spire_pubkey_hex,
|
||||||
|
"bunker_url": bunker_url,
|
||||||
|
"relays": relays,
|
||||||
|
}
|
||||||
|
blob = (
|
||||||
|
base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||||
|
.decode()
|
||||||
|
.rstrip("=")
|
||||||
|
)
|
||||||
|
return SEED_URL_SCHEME + blob
|
||||||
|
|
||||||
|
|
||||||
|
async def pair_spire(
|
||||||
|
machine: Machine,
|
||||||
|
*,
|
||||||
|
relays: list[str],
|
||||||
|
admin_client: NsecBunkerAdminClient,
|
||||||
|
bunker_relay: str | None = None,
|
||||||
|
keystore_passphrase: str | None = None,
|
||||||
|
) -> PairResult:
|
||||||
|
"""Mint a bunker-held key + scoped connect token for `machine` and
|
||||||
|
return the seed URL the spire redeems at first boot.
|
||||||
|
|
||||||
|
`admin_client` must already be connected (the caller owns the
|
||||||
|
`async with NsecBunkerAdminClient.from_settings()` context) — keeps
|
||||||
|
connection lifecycle out of the orchestration so this is unit-testable
|
||||||
|
with a fake client.
|
||||||
|
|
||||||
|
`bunker_relay` / `keystore_passphrase` default to the lnbits bunker
|
||||||
|
settings; injectable for tests. `relays` are the relays the spire will
|
||||||
|
use for its *own* events (kind-21000/30078) — typically the operator's
|
||||||
|
nostrrelay; supplied by the API layer.
|
||||||
|
|
||||||
|
Raises PairingError on any bunker failure; no state is persisted here
|
||||||
|
(the API layer persists on success).
|
||||||
|
"""
|
||||||
|
relay = (
|
||||||
|
bunker_relay if bunker_relay is not None else settings.lnbits_nsec_bunker_url
|
||||||
|
)
|
||||||
|
passphrase = (
|
||||||
|
keystore_passphrase
|
||||||
|
if keystore_passphrase is not None
|
||||||
|
else settings.lnbits_nsec_bunker_keystore_passphrase
|
||||||
|
)
|
||||||
|
if not relay:
|
||||||
|
raise PairingError(
|
||||||
|
"LNBITS_NSEC_BUNKER_URL is not set — cannot build a spire bunker connection"
|
||||||
|
)
|
||||||
|
if not passphrase:
|
||||||
|
raise PairingError(
|
||||||
|
"LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — "
|
||||||
|
"cannot mint a spire key"
|
||||||
|
)
|
||||||
|
if not relays:
|
||||||
|
raise PairingError("at least one relay is required for the seed URL")
|
||||||
|
|
||||||
|
key_name = spire_key_name(machine.id)
|
||||||
|
client_name = f"spire-client-{machine.id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
spire_npub = await admin_client.create_new_key(key_name, passphrase)
|
||||||
|
spire_pubkey_hex = npub_to_hex(spire_npub)
|
||||||
|
policy_id = await ensure_policy(
|
||||||
|
admin_client,
|
||||||
|
name=SPIRE_POLICY_NAME,
|
||||||
|
rules=SPIRE_POLICY_RULES,
|
||||||
|
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
|
||||||
|
)
|
||||||
|
await admin_client.create_new_token(key_name, client_name, policy_id)
|
||||||
|
tokens = await admin_client.get_key_tokens(key_name)
|
||||||
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
|
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
|
||||||
|
except NsecBunkerError as exc:
|
||||||
|
raise PairingError(f"bunker admin RPC failed during pairing: {exc}") from exc
|
||||||
|
|
||||||
|
token = _recover_token(tokens, client_name)
|
||||||
|
_, _, secret = token.partition("#")
|
||||||
|
|
||||||
|
bunker_url = (
|
||||||
|
f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}"
|
||||||
|
f"&secret={quote(secret, safe='')}"
|
||||||
|
)
|
||||||
|
seed_url = build_seed_url(
|
||||||
|
spire_npub=spire_npub,
|
||||||
|
spire_pubkey_hex=spire_pubkey_hex,
|
||||||
|
bunker_url=bunker_url,
|
||||||
|
relays=relays,
|
||||||
|
)
|
||||||
|
return PairResult(
|
||||||
|
spire_npub=spire_npub,
|
||||||
|
spire_pubkey_hex=spire_pubkey_hex,
|
||||||
|
bunker_key_name=key_name,
|
||||||
|
bunker_url=bunker_url,
|
||||||
|
seed_url=seed_url,
|
||||||
|
)
|
||||||
120
tests/test_pair_endpoint.py
Normal file
120
tests/test_pair_endpoint.py
Normal file
|
|
@ -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
|
||||||
253
tests/test_pairing.py
Normal file
253
tests/test_pairing.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"""Unit tests for the seed-URL pairing service (S0 / #9, model A1).
|
||||||
|
|
||||||
|
The bunker admin client is faked — these exercise the orchestration
|
||||||
|
(create_new_key -> ensure-policy -> create_new_token -> get_key_tokens),
|
||||||
|
the policy reconciliation, and the seed-URL / bunker:// wire shape, with
|
||||||
|
no live nsecbunkerd. npub<->hex round-trips through lnbits' real helpers
|
||||||
|
so the parsing is exercised for real.
|
||||||
|
|
||||||
|
Async is driven via asyncio.run (this venv has no pytest-asyncio), matching
|
||||||
|
the rest of the suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lnbits.utils.nostr import hex_to_npub
|
||||||
|
|
||||||
|
from ..models import Machine
|
||||||
|
from ..pairing import (
|
||||||
|
SEED_URL_SCHEME,
|
||||||
|
SPIRE_POLICY_METHODS_NO_KIND,
|
||||||
|
SPIRE_POLICY_NAME,
|
||||||
|
SPIRE_POLICY_RULES,
|
||||||
|
PairingError,
|
||||||
|
build_seed_url,
|
||||||
|
pair_spire,
|
||||||
|
spire_key_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
|
||||||
|
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
|
||||||
|
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
|
||||||
|
_RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"]
|
||||||
|
_BUNKER_RELAY = "wss://bunker.internal/relay"
|
||||||
|
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_policy_cache():
|
||||||
|
# lnbits' ensure_policy caches resolved policy ids on
|
||||||
|
# (admin_pubkey, name); clear between tests so each FakeBunker's
|
||||||
|
# canned policy state is honoured rather than a stale cached id.
|
||||||
|
from lnbits.core.signers import remote_bunker
|
||||||
|
|
||||||
|
remote_bunker._POLICY_ID_CACHE.clear()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _machine(mid: str = "m1") -> Machine:
|
||||||
|
return Machine(
|
||||||
|
id=mid,
|
||||||
|
operator_user_id="op1",
|
||||||
|
machine_npub="placeholder",
|
||||||
|
wallet_id="w1",
|
||||||
|
name="sintra",
|
||||||
|
location=None,
|
||||||
|
fiat_code="EUR",
|
||||||
|
is_active=True,
|
||||||
|
created_at=_NOW,
|
||||||
|
updated_at=_NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBunker:
|
||||||
|
"""Records calls; returns canned bunker responses."""
|
||||||
|
|
||||||
|
admin_pubkey = "fake-admin-pubkey"
|
||||||
|
|
||||||
|
# pragma: allowlist secret
|
||||||
|
def __init__(self, *, policies=None, token_secret="s3cr3t"):
|
||||||
|
self._policies = policies or []
|
||||||
|
self._token_secret = token_secret
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
self._next_policy_id = 7
|
||||||
|
|
||||||
|
async def create_new_key(self, name, passphrase):
|
||||||
|
self.calls.append(("create_new_key", name, passphrase))
|
||||||
|
return _SPIRE_NPUB
|
||||||
|
|
||||||
|
async def get_policies(self):
|
||||||
|
self.calls.append(("get_policies",))
|
||||||
|
return list(self._policies)
|
||||||
|
|
||||||
|
async def create_new_policy(self, name, rules):
|
||||||
|
self.calls.append(("create_new_policy", name, rules))
|
||||||
|
pid = self._next_policy_id
|
||||||
|
self._policies.append({"id": pid, "name": name, "rules": list(rules)})
|
||||||
|
return pid
|
||||||
|
|
||||||
|
async def add_policy_rule(self, policy_id, rule):
|
||||||
|
self.calls.append(("add_policy_rule", policy_id, rule))
|
||||||
|
|
||||||
|
async def create_new_token(self, key_name, client_name, policy_id):
|
||||||
|
self.calls.append(("create_new_token", key_name, client_name, policy_id))
|
||||||
|
|
||||||
|
async def get_key_tokens(self, key_name):
|
||||||
|
self.calls.append(("get_key_tokens", key_name))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"clientName": f"spire-client-{key_name.split('-', 1)[1]}",
|
||||||
|
"token": f"{_SPIRE_NPUB}#{self._token_secret}",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def named(self, name):
|
||||||
|
return [c for c in self.calls if c[0] == name]
|
||||||
|
|
||||||
|
|
||||||
|
def _pair(bunker, machine=None):
|
||||||
|
return asyncio.run(
|
||||||
|
pair_spire(
|
||||||
|
machine or _machine(),
|
||||||
|
relays=_RELAYS,
|
||||||
|
admin_client=bunker,
|
||||||
|
bunker_relay=_BUNKER_RELAY,
|
||||||
|
keystore_passphrase=_PASSPHRASE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_happy_path_mints_key_policy_token():
|
||||||
|
bunker = FakeBunker(token_secret="abc123") # pragma: allowlist secret
|
||||||
|
result = _pair(bunker)
|
||||||
|
|
||||||
|
assert ("create_new_key", "spire-m1", _PASSPHRASE) in bunker.calls
|
||||||
|
assert result.bunker_key_name == spire_key_name("m1") == "spire-m1"
|
||||||
|
|
||||||
|
assert result.spire_npub == _SPIRE_NPUB
|
||||||
|
assert result.spire_pubkey_hex == _SPIRE_HEX
|
||||||
|
|
||||||
|
created = bunker.named("create_new_policy")
|
||||||
|
assert created and created[0][1] == SPIRE_POLICY_NAME
|
||||||
|
token_call = bunker.named("create_new_token")[0]
|
||||||
|
assert token_call[1] == "spire-m1" # key_name
|
||||||
|
assert token_call[2] == "spire-client-m1" # client_name
|
||||||
|
assert token_call[3] == 7 # policy_id from the fake's create_new_policy
|
||||||
|
|
||||||
|
|
||||||
|
def test_bunker_url_carries_pubkey_relay_secret():
|
||||||
|
result = _pair(FakeBunker(token_secret="topsecret")) # pragma: allowlist secret
|
||||||
|
assert result.bunker_url.startswith(f"bunker://{_SPIRE_HEX}?")
|
||||||
|
assert "relay=wss%3A%2F%2Fbunker.internal%2Frelay" in result.bunker_url
|
||||||
|
assert "secret=topsecret" in result.bunker_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_url_decodes_to_contract():
|
||||||
|
result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret
|
||||||
|
assert result.seed_url.startswith(SEED_URL_SCHEME)
|
||||||
|
blob = result.seed_url[len(SEED_URL_SCHEME) :]
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
||||||
|
assert payload == {
|
||||||
|
"v": 1,
|
||||||
|
"spire_npub": _SPIRE_NPUB,
|
||||||
|
"spire_pubkey": _SPIRE_HEX,
|
||||||
|
"bunker_url": result.bunker_url,
|
||||||
|
"relays": _RELAYS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_policy_adds_kindless_nip44_rules():
|
||||||
|
bunker = FakeBunker() # no existing policies
|
||||||
|
_pair(bunker)
|
||||||
|
added = [c[2]["method"] for c in bunker.named("add_policy_rule")]
|
||||||
|
# kind-scoped rules went in via create_new_policy; only the kind-less
|
||||||
|
# nip44 methods are reconciled in via add_policy_rule.
|
||||||
|
assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_policy_reused_not_recreated():
|
||||||
|
existing = [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"name": SPIRE_POLICY_NAME,
|
||||||
|
"rules": [dict(r) for r in SPIRE_POLICY_RULES],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
bunker = FakeBunker(policies=existing)
|
||||||
|
_pair(bunker)
|
||||||
|
assert not bunker.named("create_new_policy") # reused, not recreated
|
||||||
|
assert bunker.named("create_new_token")[0][3] == 42 # used existing id
|
||||||
|
added = [c[2]["method"] for c in bunker.named("add_policy_rule")]
|
||||||
|
assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fully_provisioned_policy_adds_nothing():
|
||||||
|
rules = [dict(r) for r in SPIRE_POLICY_RULES] + [
|
||||||
|
{"method": m, "kind": None} for m in SPIRE_POLICY_METHODS_NO_KIND
|
||||||
|
]
|
||||||
|
bunker = FakeBunker(policies=[{"id": 9, "name": SPIRE_POLICY_NAME, "rules": rules}])
|
||||||
|
_pair(bunker)
|
||||||
|
assert not bunker.named("add_policy_rule")
|
||||||
|
assert not bunker.named("create_new_policy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_token_raises():
|
||||||
|
bunker = FakeBunker()
|
||||||
|
|
||||||
|
async def _bad_tokens(key_name):
|
||||||
|
_ = key_name
|
||||||
|
return [{"token": "no-hash-here"}]
|
||||||
|
|
||||||
|
bunker.get_key_tokens = _bad_tokens
|
||||||
|
with pytest.raises(PairingError, match="malformed token"):
|
||||||
|
_pair(bunker)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_relay_or_passphrase_raises():
|
||||||
|
with pytest.raises(PairingError, match="LNBITS_NSEC_BUNKER_URL"):
|
||||||
|
asyncio.run(
|
||||||
|
pair_spire(
|
||||||
|
_machine(),
|
||||||
|
relays=_RELAYS,
|
||||||
|
admin_client=FakeBunker(),
|
||||||
|
bunker_relay="",
|
||||||
|
keystore_passphrase=_PASSPHRASE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(PairingError, match="PASSPHRASE"):
|
||||||
|
asyncio.run(
|
||||||
|
pair_spire(
|
||||||
|
_machine(),
|
||||||
|
relays=_RELAYS,
|
||||||
|
admin_client=FakeBunker(),
|
||||||
|
bunker_relay=_BUNKER_RELAY,
|
||||||
|
keystore_passphrase="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(PairingError, match="relay is required"):
|
||||||
|
asyncio.run(
|
||||||
|
pair_spire(
|
||||||
|
_machine(),
|
||||||
|
relays=[],
|
||||||
|
admin_client=FakeBunker(),
|
||||||
|
bunker_relay=_BUNKER_RELAY,
|
||||||
|
keystore_passphrase=_PASSPHRASE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_seed_url_roundtrip():
|
||||||
|
url = build_seed_url(
|
||||||
|
spire_npub=_SPIRE_NPUB,
|
||||||
|
spire_pubkey_hex=_SPIRE_HEX,
|
||||||
|
bunker_url="bunker://x?relay=r&secret=s",
|
||||||
|
relays=_RELAYS,
|
||||||
|
)
|
||||||
|
blob = url[len(SEED_URL_SCHEME) :]
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
||||||
|
assert payload["spire_pubkey"] == _SPIRE_HEX
|
||||||
|
assert payload["relays"] == _RELAYS
|
||||||
53
views_api.py
53
views_api.py
|
|
@ -5,12 +5,18 @@
|
||||||
# LNbits instance can never see each other's machines, settlements, or
|
# LNbits instance can never see each other's machines, settlements, or
|
||||||
# clients. The super-only platform-fee write endpoint lands in P2.
|
# clients. The super-only platform-fee write endpoint lands in P2.
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from lnbits.core.crud import get_wallet
|
from lnbits.core.crud import get_wallet
|
||||||
from lnbits.core.crud.users import get_account_by_pubkey
|
from lnbits.core.crud.users import get_account_by_pubkey
|
||||||
from lnbits.core.models import User
|
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.decorators import check_super_user, check_user_exists
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
|
||||||
|
|
@ -23,6 +29,7 @@ from .cassette_transport import (
|
||||||
publish_to_atm,
|
publish_to_atm,
|
||||||
)
|
)
|
||||||
from .fee_transport import publish_fee_config
|
from .fee_transport import publish_fee_config
|
||||||
|
from .pairing import PairResult, PairingError, pair_spire
|
||||||
from .crud import (
|
from .crud import (
|
||||||
append_settlement_note,
|
append_settlement_note,
|
||||||
count_completed_legs_for_settlement,
|
count_completed_legs_for_settlement,
|
||||||
|
|
@ -55,6 +62,7 @@ from .crud import (
|
||||||
lp_is_onboarded,
|
lp_is_onboarded,
|
||||||
replace_commission_splits,
|
replace_commission_splits,
|
||||||
reset_settlement_for_retry,
|
reset_settlement_for_retry,
|
||||||
|
set_machine_pairing,
|
||||||
update_cassette_config,
|
update_cassette_config,
|
||||||
update_dca_client,
|
update_dca_client,
|
||||||
update_deposit,
|
update_deposit,
|
||||||
|
|
@ -80,6 +88,7 @@ from .models import (
|
||||||
DcaPayment,
|
DcaPayment,
|
||||||
DcaSettlement,
|
DcaSettlement,
|
||||||
Machine,
|
Machine,
|
||||||
|
PairMachineData,
|
||||||
PartialDispenseData,
|
PartialDispenseData,
|
||||||
PublishCassettesPayload,
|
PublishCassettesPayload,
|
||||||
SetCommissionSplitsData,
|
SetCommissionSplitsData,
|
||||||
|
|
@ -274,6 +283,50 @@ async def api_create_machine(
|
||||||
return 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])
|
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
||||||
async def api_list_machines(
|
async def api_list_machines(
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue