diff --git a/pairing.py b/pairing.py index b65b760..8629de9 100644 --- a/pairing.py +++ b/pairing.py @@ -57,20 +57,25 @@ 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) +# - 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. +# (rule.kind.toString()). Mirrors lnbits' DEFAULT_POLICY_* split. nip04 is +# deliberately absent — the v1/nip04 path is dead code (bitspire#52). # -# 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. +# 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}, diff --git a/static/js/index.js b/static/js/index.js index f510e9c..52d3a3b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -191,6 +191,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, @@ -781,6 +789,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.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) // ----------------------------------------------------------------- diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index b58714e..7bdebd3 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -131,6 +131,17 @@
+
Edit
+
+ ${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' }
+
+
+ Revoke spire access
+
@@ -821,6 +843,97 @@
+
+
+
+
+
+
+ Pair spire
+
+
+
+
+
+
+
+ Mints a dedicated signing key for
+
+ inside the operator bunker and issues a one-shot seed URL. The
+ spire's key never touches its disk; its cash-outs route to this
+ machine's wallet. Re-pairing issues a fresh seed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Paired. Scan this on the spire at first boot, or paste the seed URL
+ into provision-atm. Shown once — copy it now.
+
+
+
+
+
+
+
+
+
+ Copy seed URL
+
+
+
+
+
+ Spire identity:
+
+
+ Copy npub
+
+
+
+
+
+
+
+
+
diff --git a/tests/test_pairing.py b/tests/test_pairing.py
index 4deb5a0..54f00ca 100644
--- a/tests/test_pairing.py
+++ b/tests/test_pairing.py
@@ -305,3 +305,14 @@ def test_revoke_spire_maps_bunker_error():
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