feat(pairing): seed-URL pairing — operator-side producer (S0 / #9) #21
2 changed files with 21 additions and 51 deletions
refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9)
Some checks failed
ci.yml / refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) (pull_request) Failing after 0s
Some checks failed
ci.yml / refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) (pull_request) Failing after 0s
Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the public ensure_policy(client, name='spirekeeper-spire', rules=..., methods_no_kind=...) instead of spirekeeper's cache-free _ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on (admin_pubkey, policy_name), so the shared helper no longer returns the wrong (lnbits-default) id for a non-default policy name — the exact reason the duplicate existed. Net -45 LOC, one less fork-divergent reimplementation to keep in sync. Requires lnbits >= the #55 merge (ensure_policy importable) — already true on dev/demo. Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits' _POLICY_ID_CACHE between tests (the shared helper caches, unlike the old local one). 203 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commit
9c5f07c72e
59
pairing.py
59
pairing.py
|
|
@ -14,7 +14,7 @@ reference for the admin chain):
|
||||||
spirekeeper (here) spire, at first boot (bitspire#52)
|
spirekeeper (here) spire, at first boot (bitspire#52)
|
||||||
────────────────── ──────────────────────────────────
|
────────────────── ──────────────────────────────────
|
||||||
1. create_new_key 5. NIP-46 connect — redeem the token with a
|
1. create_new_key 5. NIP-46 connect — redeem the token with a
|
||||||
2. _ensure_spire_policy freshly-generated *client* keypair; bunker
|
2. ensure_policy freshly-generated *client* keypair; bunker
|
||||||
3. create_new_token binds (client_pubkey → spire key). The
|
3. create_new_token binds (client_pubkey → spire key). The
|
||||||
4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the
|
4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the
|
||||||
(package token in URL) signing key never leaves the bunker.
|
(package token in URL) signing key never leaves the bunker.
|
||||||
|
|
@ -46,6 +46,7 @@ from lnbits.core.services.nsec_bunker import (
|
||||||
NsecBunkerNotConfiguredError,
|
NsecBunkerNotConfiguredError,
|
||||||
npub_to_hex,
|
npub_to_hex,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.signers.remote_bunker import ensure_policy
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -103,55 +104,6 @@ def spire_key_name(machine_id: str) -> str:
|
||||||
return f"spire-{machine_id}"
|
return f"spire-{machine_id}"
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_spire_policy(client: NsecBunkerAdminClient) -> int:
|
|
||||||
"""Find-or-create the shared `spirekeeper-spire` policy and reconcile
|
|
||||||
(add-only) any missing rules. Returns its bunker-assigned id.
|
|
||||||
|
|
||||||
Mirrors lnbits' `_ensure_policy` but with the spire rule set and **no
|
|
||||||
admin-pubkey cache** — lnbits' cache is keyed on `admin_pubkey` alone
|
|
||||||
(remote_bunker.py:157), so reusing its helper for a second policy name
|
|
||||||
would return the wrong (cached lnbits-default) id. Pairing is an
|
|
||||||
infrequent operator action, so the extra `get_policies` round-trip is
|
|
||||||
cheap and correct. (Filed as an lnbits follow-up: cache key should
|
|
||||||
include policy_name.)
|
|
||||||
|
|
||||||
Add-only, never remove: the policy may be shared across spires (and in
|
|
||||||
principle other consumers); removing a rule would affect them all.
|
|
||||||
"""
|
|
||||||
policies = await client.get_policies()
|
|
||||||
existing = [p for p in policies if p.get("name") == SPIRE_POLICY_NAME]
|
|
||||||
if existing:
|
|
||||||
policy = max(existing, key=lambda p: int(p["id"]))
|
|
||||||
policy_id = int(policy["id"])
|
|
||||||
live_rules = policy.get("rules") or []
|
|
||||||
else:
|
|
||||||
policy_id = await client.create_new_policy(
|
|
||||||
SPIRE_POLICY_NAME, SPIRE_POLICY_RULES
|
|
||||||
)
|
|
||||||
live_rules = list(SPIRE_POLICY_RULES)
|
|
||||||
|
|
||||||
def _norm(method: str, kind) -> tuple[str, str | None]:
|
|
||||||
# nsecbunkerd stores kind as a string; None = kind-less rule.
|
|
||||||
return (method, str(kind) if kind is not None else None)
|
|
||||||
|
|
||||||
live_keys = {
|
|
||||||
_norm(r["method"], r.get("kind"))
|
|
||||||
for r in live_rules
|
|
||||||
if isinstance(r, dict) and r.get("method")
|
|
||||||
}
|
|
||||||
for rule in SPIRE_POLICY_RULES:
|
|
||||||
key = _norm(rule["method"], rule.get("kind"))
|
|
||||||
if key not in live_keys:
|
|
||||||
await client.add_policy_rule(policy_id, rule)
|
|
||||||
live_keys.add(key)
|
|
||||||
for method in SPIRE_POLICY_METHODS_NO_KIND:
|
|
||||||
key = _norm(method, None)
|
|
||||||
if key not in live_keys:
|
|
||||||
await client.add_policy_rule(policy_id, {"method": method})
|
|
||||||
live_keys.add(key)
|
|
||||||
return policy_id
|
|
||||||
|
|
||||||
|
|
||||||
def _recover_token(tokens: list[dict], client_name: str) -> str:
|
def _recover_token(tokens: list[dict], client_name: str) -> str:
|
||||||
"""Pull the freshly-issued `<npub>#<secret>` token out of the bunker's
|
"""Pull the freshly-issued `<npub>#<secret>` token out of the bunker's
|
||||||
`get_key_tokens` response. Match by client name when the bunker
|
`get_key_tokens` response. Match by client name when the bunker
|
||||||
|
|
@ -238,7 +190,12 @@ async def pair_spire(
|
||||||
try:
|
try:
|
||||||
spire_npub = await admin_client.create_new_key(key_name, passphrase)
|
spire_npub = await admin_client.create_new_key(key_name, passphrase)
|
||||||
spire_pubkey_hex = npub_to_hex(spire_npub)
|
spire_pubkey_hex = npub_to_hex(spire_npub)
|
||||||
policy_id = await _ensure_spire_policy(admin_client)
|
policy_id = await ensure_policy(
|
||||||
|
admin_client,
|
||||||
|
name=SPIRE_POLICY_NAME,
|
||||||
|
rules=SPIRE_POLICY_RULES,
|
||||||
|
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
|
||||||
|
)
|
||||||
await admin_client.create_new_token(key_name, client_name, policy_id)
|
await admin_client.create_new_token(key_name, client_name, policy_id)
|
||||||
tokens = await admin_client.get_key_tokens(key_name)
|
tokens = await admin_client.get_key_tokens(key_name)
|
||||||
except NsecBunkerNotConfiguredError as exc:
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,17 @@ _BUNKER_RELAY = "wss://bunker.internal/relay"
|
||||||
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret
|
_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:
|
def _machine(mid: str = "m1") -> Machine:
|
||||||
return Machine(
|
return Machine(
|
||||||
id=mid,
|
id=mid,
|
||||||
|
|
@ -56,6 +67,8 @@ def _machine(mid: str = "m1") -> Machine:
|
||||||
class FakeBunker:
|
class FakeBunker:
|
||||||
"""Records calls; returns canned bunker responses."""
|
"""Records calls; returns canned bunker responses."""
|
||||||
|
|
||||||
|
admin_pubkey = "fake-admin-pubkey"
|
||||||
|
|
||||||
# pragma: allowlist secret
|
# pragma: allowlist secret
|
||||||
def __init__(self, *, policies=None, token_secret="s3cr3t"):
|
def __init__(self, *, policies=None, token_secret="s3cr3t"):
|
||||||
self._policies = policies or []
|
self._policies = policies or []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue