Some checks failed
ci.yml / docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27) (pull_request) Failing after 0s
nsecbunkerd#27 (deployed 2026-06-19) reverses the #24 finding: the sign-time ACL now evaluates token lifecycle live on every request (checkIfPubkeyAllowed step 4 joins through a liveWhere filter; applyToken stopped photocopying grants into SigningConditions). So: - duration_hours / token expiresAt now bounds an ESTABLISHED binding — an expired token stops signing post-bind, not just at connect. The prior docstring (connect-window-only, pointing at the now-closed nsecbunkerd#24) is corrected. - Token-revoke is no longer a post-redeem no-op (closes the #22 mechanism bunker-side). revoke_spire keeps using revoke_key_user because that's the subject-level ban cutting the whole binding, not just one token's grant — rationale updated, behavior unchanged. Doc/comment only; 20 pairing tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
279 lines
11 KiB
Python
279 lines
11 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):
|
|
|
|
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
|
|
# - 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
|
|
|
|
|
|
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.
|
|
|
|
`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, 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
|