feat(federation): signing-details panel on the Nostr discoverability strip
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m41s

Adds a 'Signing details' disclosure showing who signs the presence event —
a locked identity row (styled like the NostrIdentityPicker overlay) fixed to
the node's own discovery key, deliberately not pickable so personal
identities can never be used for node discovery — plus a human-readable
breakdown of the signed content: DID, signing npub, software version, and
the event format (kind 30078, NIP-33 replaceable, d-tag archipelago-node),
with a note that every event carries a NIP-01 Schnorr signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 02:18:58 -04:00
parent 73923d0ffd
commit 50d5d4d132

View File

@ -52,36 +52,108 @@
manages everything related to peering. The toggle directly mutates
the `nostr_discovery_enabled` config flag backend defaults to OFF
and nothing is published until the user explicitly turns it on. -->
<div class="glass-card p-4 mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Nostr discoverability</span>
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
<div class="glass-card p-4 mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Nostr discoverability</span>
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
</div>
<p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only never an onion)
so other nodes can find you and request to peer. Inbound requests land in the
panel below for your approval. Off by default.
</p>
<button
class="text-xs text-white/50 hover:text-white/80 underline underline-offset-2 mt-1"
@click="showSigningInfo = !showSigningInfo"
>
{{ showSigningInfo ? 'Hide signing details' : 'Signing details' }}
</button>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discoveryToggling"
@click="toggleDiscovery"
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
</div>
<p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only never an onion)
so other nodes can find you and request to peer. Inbound requests land in the
panel below for your approval. Off by default.
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discoveryToggling"
@click="toggleDiscovery"
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
<!-- Signing details: shows WHO signs the presence event (always the
node's own discovery key — deliberately not pickable, so a user's
personal Web5/Nostr identities can never be burned for node
discovery) and a human-readable view of WHAT gets signed. -->
<div v-if="showSigningInfo" class="mt-4 pt-4 border-t border-white/10 space-y-3">
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">Signer</label>
<!-- Same row style as the NostrIdentityPicker overlay, but locked:
node discovery always signs with the node's own key, never a
personal identity, so there is nothing to pick. -->
<div class="w-full sm:max-w-md p-3 rounded-lg bg-white/[0.03] ring-1 ring-white/10">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 bg-white/10 text-white/80">
<span class="text-sm font-bold">{{ (appStore.serverName || 'N').charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ appStore.serverName || 'This node' }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/60">node identity</span>
</div>
<div class="mt-0.5">
<span class="text-white/35 text-xs font-mono truncate">{{ nodeNpub ? truncateNpub(nodeNpub) : 'loading…' }}</span>
</div>
</div>
<svg class="w-4 h-4 text-white/30 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Locked">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
</div>
<p class="text-[11px] text-white/40 mt-1">
Presence events are always signed with this node's dedicated discovery key.
Your personal identities are never used for node discovery, so unlike the app
sign-in overlay there is nothing to choose here.
</p>
</div>
<div>
<label class="block text-[10px] uppercase tracking-wide text-white/40 mb-1">What gets signed &amp; published</label>
<dl class="text-xs space-y-1.5">
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Identity (DID)</dt>
<dd class="text-white/80 font-mono break-all">{{ selfDid || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Signing key</dt>
<dd class="text-white/80 font-mono break-all">{{ nodeNpub || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Software version</dt>
<dd class="text-white/80">{{ appVersion || '—' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:gap-2">
<dt class="text-white/50 shrink-0 sm:w-28">Event format</dt>
<dd class="text-white/80">Nostr kind 30078, replaceable (NIP-33), tag <span class="font-mono">archipelago-node</span></dd>
</div>
</dl>
<p class="text-[11px] text-white/40 mt-2">
Every presence event carries a Schnorr signature (NIP-01) made with the key
above relays reject unsigned events, so anything you see via discovery was
cryptographically signed by its node. Your onion address is never part of this
event; it is only shared over encrypted DMs (NIP-44) after you approve a peer.
</p>
</div>
</div>
</div>
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
@ -148,6 +220,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
import NetworkMap from '@/components/federation/NetworkMap.vue'
import FederationHeader from './federation/FederationHeader.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
@ -163,6 +236,7 @@ import { nodeName, timeAgo } from './federation/utils'
const transportStore = useTransportStore()
const appStore = useAppStore()
const syncStore = useSyncStore()
const nodes = ref<FederatedNode[]>([])
const loading = ref(true)
@ -271,6 +345,25 @@ const discoveryEnabled = ref(false)
const discoveryToggling = ref(false)
const discoveryError = ref('')
const showDiscoverModal = ref(false)
// Signing-details disclosure: who signs the presence event (always the node's
// own discovery key) and a human-readable view of the signed content.
const showSigningInfo = ref(false)
const nodeNpub = ref('')
const appVersion = computed(() => syncStore.serverInfo?.version || '')
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
async function loadNodeNpub() {
try {
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
if (res?.nostr_npub) nodeNpub.value = res.nostr_npub
} catch {
// Nostr key not provisioned yet panel shows a placeholder
}
}
const pendingRequests = ref<PendingPeerRequest[]>([])
const pollingHandshake = ref(false)
const pendingBusyId = ref<string | null>(null)
@ -557,6 +650,7 @@ onMounted(async () => {
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
loadNodeNpub()
transportStore.fetchPeers()
try {
const result = await rpcClient.getNodeDid()