spirekeeper/pairing.py
Padreug 2b90590104
Some checks failed
ci.yml / fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) (pull_request) Failing after 0s
fix(pairing): retry all bunker admin RPCs past transient timeouts (#38)
The get_key_tokens retry only covered an empty token list; a transient
NsecBunkerTimeoutError on any admin RPC still failed pairing with a 502 (seen
live on aio-demo: create_new_key and get_key_tokens both 15s-timed-out, then a
manual retry succeeded). Generalise to `_bunker_retry`, wrapping every admin
call in the pair_spire chain (create_new_key, ensure_policy, create_new_token,
get_key_tokens): a NsecBunkerTimeoutError (and, for get_key_tokens, an empty
list) is transient → retry with backoff; a NsecBunkerRpcError rejection or
misconfig is terminal → fail fast. create_new_key is replace-by-name and
ensure_policy reconciles idempotently, so retrying on timeout is safe.

Also: _validate_relay now rejects a host-less ws:// (mint was laxer than the
consumer's parseSpireSeed), and stale "nostrclient" comments in the pair UI +
a test are corrected to "transport relay".

229 tests pass (incl. timeout-retry + fail-fast-rejection coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:30:55 +02:00

419 lines
18 KiB
Python

"""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, slimmed in bitspire#70):
spire-seed:v1:<base64url(json)> json = {
"v": 1,
"spire_npub": "npub1…", # the bunker-minted spire identity
"lnbits_npub": "npub1…", # this lnbits' nostr-transport server id
"bunker_secret": "<sec>", # one-shot NIP-46 connect token
"relays": ["wss://…"], # relays for the spire's own events
"bunker_relay": "wss://…", # OPTIONAL — omitted when == relays[0]
}
The pubkey is carried ONCE, as an npub: the consumer derives spire_pubkey (hex)
and reconstructs the bunker:// URL from spire_npub + bunker_relay|relays[0] +
bunker_secret. `lnbits_npub` lets a paired machine reach this lnbits' transport
with nothing else provisioned. See bitspire packages/nostr-client/src/seed.ts.
"""
from __future__ import annotations
import asyncio
import base64
import json
import re
from urllib.parse import quote, urlparse
from lnbits.core.services.nsec_bunker import (
NsecBunkerAdminClient,
NsecBunkerError,
NsecBunkerNotConfiguredError,
NsecBunkerTimeoutError,
npub_to_hex,
)
from lnbits.core.signers.remote_bunker import ensure_policy
from lnbits.settings import settings
from lnbits.utils.nostr import hex_to_npub
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 `<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
async def _bunker_retry(call, *, attempts: int = 5, delay: float = 0.4, retry_empty: bool = False):
"""Retry a bunker admin RPC past a TRANSIENT failure — a `NsecBunkerTimeoutError`
(the nsecbunkerd briefly slow / unresponsive) and, when `retry_empty`, an empty
result (a just-issued token not yet listed). Both are seen intermittently on a
healthy bunker (bitspire#70 / spirekeeper#38). A real rejection
(`NsecBunkerRpcError`) or misconfig (`NsecBunkerNotConfiguredError`) is terminal
and propagates immediately. `call` is a zero-arg coroutine factory."""
for attempt in range(attempts):
last = attempt == attempts - 1
try:
result = await call()
except NsecBunkerTimeoutError:
if last:
raise
await asyncio.sleep(delay)
continue
if retry_empty and not result and not last:
await asyncio.sleep(delay)
continue
return result
raise AssertionError("unreachable: the final attempt returns or raises")
def build_seed_url(
*,
spire_npub: str,
lnbits_npub: str,
bunker_secret: str,
relays: list[str],
bunker_relay: str | None = None,
) -> str:
"""Build the slim seed URL (bitspire#70). The pubkey rides once as
`spire_npub`; the consumer derives the hex + reconstructs `bunker_url` from
`bunker_secret` + `bunker_relay` (or `relays[0]`). `bunker_relay` is emitted
only when it differs from `relays[0]`, keeping the common case one field
lighter."""
payload: dict = {
"v": 1,
"spire_npub": spire_npub,
"lnbits_npub": lnbits_npub,
"bunker_secret": bunker_secret,
"relays": relays,
}
if bunker_relay and relays and bunker_relay != relays[0]:
payload["bunker_relay"] = bunker_relay
blob = (
base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
.decode()
.rstrip("=")
)
return SEED_URL_SCHEME + blob
_WS_SCHEME_RE = re.compile(r"^wss?://", re.IGNORECASE)
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
def _baseurl_ws() -> str | None:
"""`settings.lnbits_baseurl` as a ws(s):// scheme+host (no trailing slash)."""
base = (settings.lnbits_baseurl or "").strip().rstrip("/")
if base.startswith("https://"):
return "wss://" + base[len("https://") :]
if base.startswith("http://"):
return "ws://" + base[len("http://") :]
return None
def default_relay_endpoint() -> str | None:
"""Derive the machine-facing relay from lnbits' nostr-transport config, so a
seed points at the relay the transport ACTUALLY listens on (bitspire#70).
NOTE: this is the nostr *relay* (a full relay that acks EVENTs), NOT the
nostrclient proxy — the client multiplexer forwards subscriptions but never
sends `OK`, so a transport client that awaits `OK` on publish times out.
If the transport relay is already machine-reachable, use it as-is; if it's a
co-located loopback relay (the bundled nostrrelay at
`ws://localhost:5001/nostrrelay/<id>`), re-home its path on lnbits' public
base URL so a remote ATM can reach the same relay. Returns None if neither
a transport relay nor a base URL is available."""
relays = settings.nostr_transport_relays or []
if not relays:
return None
relay = relays[0]
host = (urlparse(relay).hostname or "").lower()
if host and host not in _LOCAL_HOSTS and not host.endswith(".localhost"):
return relay # already remote-reachable
base = _baseurl_ws()
if not base:
return None
return base + urlparse(relay).path
def _validate_relay(url: str, field: str) -> None:
"""A seed relay must be a `ws(s)://` URL reachable by a REMOTE machine. Reject
loopback hosts: a localhost relay in a seed is the exact unreachable case
that silently crash-loops pairing (bitspire#70)."""
if not isinstance(url, str) or not _WS_SCHEME_RE.match(url):
raise PairingError(f"{field} must be a ws:// or wss:// URL (got {url!r})")
host = (urlparse(url).hostname or "").lower()
if not host:
raise PairingError(f"{field} has no host ({url!r})")
if host in _LOCAL_HOSTS or host.endswith(".localhost"):
raise PairingError(
f"{field} points at localhost ({url!r}). A pairing seed is redeemed by "
"a REMOTE machine — set lnbits_baseurl (and any explicit relay) to a "
"machine-reachable address."
)
async def pair_spire(
machine: Machine,
*,
relays: list[str] | None = None,
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). When omitted, they default to the relay the transport
listens on (`default_relay_endpoint`, from `nostr_transport_relays` re-homed
on `lnbits_baseurl`) so a seed points at a relay that actually acks EVENTs.
Every relay (and `bunker_relay`) is validated as a remote-reachable
`ws(s)://` URL — a loopback host is rejected (bitspire#70).
`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. All 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"
)
# Default the spire's event relay(s) to the transport's own relay (re-homed
# on the base URL) when the caller gives none.
if not relays:
endpoint = default_relay_endpoint()
if not endpoint:
raise PairingError(
"no relays given and none could be derived — set "
"nostr_transport_relays (and lnbits_baseurl if it's a loopback relay)"
)
relays = [endpoint]
# Validate every relay is a remote-reachable ws(s):// URL (rejects loopback +
# the ws://→As:// class of corruption). The bunker relay is validated too:
# it's baked into `bunker_url` where the *spire* (remote ATM) reaches the
# bunker, so it must be machine-reachable — NOT `settings.lnbits_nsec_bunker_url`
# (the co-located ws://127.0.0.1 path). Default it to the spire's own event
# relay; an explicit `bunker_relay` overrides for split-relay deploys.
for i, r in enumerate(relays):
_validate_relay(r, f"relays[{i}]")
if bunker_relay:
_validate_relay(bunker_relay, "bunker_relay")
relay = bunker_relay if bunker_relay else relays[0]
key_name = spire_key_name(machine.id)
client_name = f"spire-client-{machine.id}"
try:
# Each admin RPC is retried past a transient bunker timeout (#38);
# create_new_key is replace-by-name so re-issuing on a timeout is safe,
# and ensure_policy reconciles idempotently (get_policies → reuse/create).
spire_npub = await _bunker_retry(lambda: admin_client.create_new_key(key_name, passphrase))
spire_pubkey_hex = npub_to_hex(spire_npub)
policy_id = await _bunker_retry(
lambda: ensure_policy(
admin_client,
name=SPIRE_POLICY_NAME,
rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
)
)
await _bunker_retry(
lambda: admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours
)
)
# retry_empty: a just-issued token can list empty before the write lands.
tokens = await _bunker_retry(lambda: admin_client.get_key_tokens(key_name), retry_empty=True)
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("#")
# The spire needs THIS lnbits' nostr-transport server identity to reach the
# backend from the seed alone (bitspire#70). The transport sets
# `settings.nostr_transport_public_key` at startup; if it's empty the
# transport isn't running, so we can't mint a self-sufficient seed.
lnbits_pubkey_hex = settings.nostr_transport_public_key
if not lnbits_pubkey_hex:
raise PairingError(
"LNbits nostr transport has no server pubkey "
"(settings.nostr_transport_public_key is empty) — is the transport "
"running? Cannot mint a self-sufficient seed."
)
lnbits_npub = hex_to_npub(lnbits_pubkey_hex)
# bunker_url is still returned in PairResult (operator display / audit); the
# seed itself no longer embeds it — the consumer reconstructs it.
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,
lnbits_npub=lnbits_npub,
bunker_secret=secret,
relays=relays,
bunker_relay=relay,
)
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