Compare commits
2 commits
37b46e354a
...
22678dfb4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 22678dfb4f | |||
| a18f653ca7 |
4 changed files with 230 additions and 6 deletions
17
pairing.py
17
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
|
# Policy granted to every spire's connect token. Scoped to exactly what a
|
||||||
# bitSpire signs as itself:
|
# bitSpire signs as itself:
|
||||||
# - 21000 nostr-transport cash RPC envelope to lnbits
|
# - 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
|
# - 30078 NIP-78 beacon + bitspire-cassettes-state hello-event
|
||||||
# Kind-scoped rules go in create_new_policy; kind-less methods (nip44, for
|
# 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
|
# encrypting cassette-state to the operator) are added via add_policy_rule
|
||||||
# because nsecbunkerd's create_new_policy chokes on null `kind`
|
# 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
|
# Kind set confirmed against the spire's signing sites in bitspire#52
|
||||||
# spire's actual createSignedEvent / finalizeEvent call sites. Over-granting
|
# (2026-06-18): live = 21000 + 30078 + 22242; CLINK 21001-21003 dormant but
|
||||||
# here only widens what a spire may sign *as its own key* — low blast radius —
|
# kept; nip04 unused. Under-granting = silent bunker reject, so err toward
|
||||||
# but under-granting makes the bunker reject the spire's events.
|
# inclusion (low blast radius — only widens what a spire signs as its OWN key).
|
||||||
SPIRE_POLICY_NAME = "spirekeeper-spire"
|
SPIRE_POLICY_NAME = "spirekeeper-spire"
|
||||||
SPIRE_POLICY_RULES = [
|
SPIRE_POLICY_RULES = [
|
||||||
{"method": "sign_event", "kind": 21000},
|
{"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": 21001},
|
||||||
{"method": "sign_event", "kind": 21002},
|
{"method": "sign_event", "kind": 21002},
|
||||||
{"method": "sign_event", "kind": 21003},
|
{"method": "sign_event", "kind": 21003},
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,14 @@ window.app = Vue.createApp({
|
||||||
saving: false,
|
saving: false,
|
||||||
data: {}
|
data: {}
|
||||||
},
|
},
|
||||||
|
pairDialog: {
|
||||||
|
show: false,
|
||||||
|
saving: false,
|
||||||
|
machine: null,
|
||||||
|
relays: '',
|
||||||
|
durationHours: null,
|
||||||
|
result: null
|
||||||
|
},
|
||||||
machineDetail: {
|
machineDetail: {
|
||||||
show: false,
|
show: false,
|
||||||
loading: 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 <b>${machine.name || machine.machine_npub.slice(0, 12)}</b>'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)
|
// Machine detail dialog (P9b)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,17 @@
|
||||||
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
|
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
|
||||||
<div :style="{fontSize: '0.8em', opacity: 0.6}"
|
<div :style="{fontSize: '0.8em', opacity: 0.6}"
|
||||||
v-text="props.row.location || 'No location set'"></div>
|
v-text="props.row.location || 'No location set'"></div>
|
||||||
|
<q-chip v-if="props.row.paired_at"
|
||||||
|
dense size="sm" color="green-2" text-color="green-9"
|
||||||
|
icon="link" :style="{marginTop: '2px'}">
|
||||||
|
paired
|
||||||
|
<q-tooltip>Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() }</q-tooltip>
|
||||||
|
</q-chip>
|
||||||
|
<q-chip v-else
|
||||||
|
dense size="sm" color="grey-3" text-color="grey-8"
|
||||||
|
icon="link_off" :style="{marginTop: '2px'}">
|
||||||
|
not paired
|
||||||
|
</q-chip>
|
||||||
</q-td>
|
</q-td>
|
||||||
<q-td key="machine_npub">
|
<q-td key="machine_npub">
|
||||||
<code :style="{fontSize: '0.85em'}"
|
<code :style="{fontSize: '0.85em'}"
|
||||||
|
|
@ -156,6 +167,17 @@
|
||||||
@click="openEditMachineDialog(props.row)">
|
@click="openEditMachineDialog(props.row)">
|
||||||
<q-tooltip>Edit</q-tooltip>
|
<q-tooltip>Edit</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<q-btn flat dense round size="sm" icon="qr_code_2"
|
||||||
|
color="teal"
|
||||||
|
@click="openPairDialog(props.row)">
|
||||||
|
<q-tooltip>${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' }</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn v-if="props.row.paired_at"
|
||||||
|
flat dense round size="sm" icon="link_off"
|
||||||
|
color="orange-8"
|
||||||
|
@click="confirmRevokeMachine(props.row)">
|
||||||
|
<q-tooltip>Revoke spire access</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
<q-btn flat dense round size="sm" icon="delete"
|
<q-btn flat dense round size="sm" icon="delete"
|
||||||
color="red-7"
|
color="red-7"
|
||||||
@click="confirmDeleteMachine(props.row)">
|
@click="confirmDeleteMachine(props.row)">
|
||||||
|
|
@ -821,6 +843,97 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- =============================================================== -->
|
||||||
|
<!-- PAIR SPIRE DIALOG — mint bunker key + one-shot seed URL (S0/#9) -->
|
||||||
|
<!-- =============================================================== -->
|
||||||
|
<q-dialog v-model="pairDialog.show" persistent>
|
||||||
|
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">Pair spire</div>
|
||||||
|
<q-space></q-space>
|
||||||
|
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Step 1 — configure + generate -->
|
||||||
|
<q-card-section v-if="!pairDialog.result">
|
||||||
|
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
|
||||||
|
Mints a dedicated signing key for
|
||||||
|
<b v-text="(pairDialog.machine && pairDialog.machine.name) || 'this spire'"></b>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="pairDialog.relays"
|
||||||
|
label="Relay(s) for the spire's events"
|
||||||
|
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>"
|
||||||
|
type="textarea" autogrow
|
||||||
|
class="q-mb-md"
|
||||||
|
dense outlined></q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model.number="pairDialog.durationHours"
|
||||||
|
label="Token lifetime in hours (optional)"
|
||||||
|
hint="Blank = non-expiring. Set e.g. 720 (30 days) to force periodic re-pairing."
|
||||||
|
type="number" min="1"
|
||||||
|
class="q-mb-md"
|
||||||
|
dense outlined></q-input>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions v-if="!pairDialog.result" align="right" class="text-primary">
|
||||||
|
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||||
|
<q-btn
|
||||||
|
color="primary" label="Generate seed URL" icon="vpn_key"
|
||||||
|
:loading="pairDialog.saving"
|
||||||
|
@click="submitPair"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
|
||||||
|
<!-- Step 2 — show the seed URL -->
|
||||||
|
<q-card-section v-else>
|
||||||
|
<q-banner dense rounded class="bg-green-1 text-grey-9 q-mb-md">
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="check_circle" color="green"></q-icon>
|
||||||
|
</template>
|
||||||
|
Paired. Scan this on the spire at first boot, or paste the seed URL
|
||||||
|
into <code>provision-atm</code>. Shown once — copy it now.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<div class="row justify-center q-mb-md">
|
||||||
|
<lnbits-qrcode
|
||||||
|
:value="pairDialog.result.seed_url"
|
||||||
|
:options="{width: 280}"></lnbits-qrcode>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="pairDialog.result.seed_url"
|
||||||
|
label="Seed URL"
|
||||||
|
type="textarea" autogrow readonly
|
||||||
|
class="q-mb-sm"
|
||||||
|
dense outlined>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-btn flat dense round icon="content_copy"
|
||||||
|
@click="copy(pairDialog.result.seed_url)">
|
||||||
|
<q-tooltip>Copy seed URL</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="text-caption" :style="{opacity: 0.7}">
|
||||||
|
Spire identity:
|
||||||
|
<code :style="{fontSize: '0.85em'}"
|
||||||
|
v-text="shortNpub(pairDialog.result.spire_npub)"></code>
|
||||||
|
<q-btn flat dense round size="xs" icon="content_copy"
|
||||||
|
@click="copy(pairDialog.result.spire_npub)">
|
||||||
|
<q-tooltip>Copy npub</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions v-else align="right" class="text-primary">
|
||||||
|
<q-btn flat label="Done" color="primary" v-close-popup></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
|
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
|
|
|
||||||
|
|
@ -305,3 +305,14 @@ def test_revoke_spire_maps_bunker_error():
|
||||||
bunker.revoke_key_user = _boom
|
bunker.revoke_key_user = _boom
|
||||||
with pytest.raises(PairingError, match="revoke"):
|
with pytest.raises(PairingError, match="revoke"):
|
||||||
asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue