feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s
Demo images / Build & push demo images (push) Successful in 4m22s
Promotion to Trusted is a privilege escalation — a Trusted peer can read node state, be deployed to, and is exempt from the `!= Untrusted` gates federation/DWN/messaging use. It must therefore cost a fresh proof that the person at the keyboard is the operator, not merely that a session cookie exists. Same reasoning as node.rotate-identity and TOTP setup, both of which already re-verify. Both entry points are covered: - `federation.invite` gates on the RESOLVED level, not on an explicit request for Trusted: "Link Your Nodes" sends no `trust_level` at all and falls through to the Trusted default. The invite is a bearer grant of Trusted to whoever redeems it, so minting it IS the escalation. Observer invites are untouched. - `federation.set-trust` gates only when the peer is not already Trusted, so the dropdown re-emitting its own value doesn't demand a password for a no-op. Demotion is deliberately NOT gated: making something less privileged must never be harder than leaving it alone, or the safe action becomes the inconvenient one. The backend is the sole authority on what counts as an escalation — it returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and retries only on that, so the rule lives in exactly one place and the frontend never pre-judges. TrustPasswordModal.vue (modelled on RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps back to the node's real level on change, since a cancelled or failed promotion would otherwise leave the dropdown displaying a level the node never accepted. The operator path stamps TrustSource::Manual; set_trust_level grew an `Option<TrustSource>` so automatic adjustments (the discovery-handshake demotion safety net) pass None and leave the recorded provenance alone rather than laundering an uninvited-join peer into looking approved. Follow-up, deliberately out of scope: `federation.join` also reaches Trusted when redeeming someone else's Trusted invite, with no re-auth. Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0b71f86aa
commit
24ce8b39e8
@@ -486,6 +486,18 @@ describe('RPCClient convenience methods', () => {
|
||||
expect(getLastMethod()).toBe('federation.invite')
|
||||
})
|
||||
|
||||
it('federationInvite omits password when none is given', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationInvite forwards the password for a trusted invite', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({ trust_level: 'trusted', password: 'hunter2' })
|
||||
})
|
||||
|
||||
it('federationJoin calls federation.join', async () => {
|
||||
mockSuccess({ joined: true, node: {} })
|
||||
await rpcClient.federationJoin('invite-code')
|
||||
@@ -510,6 +522,22 @@ describe('RPCClient convenience methods', () => {
|
||||
expect(getLastMethod()).toBe('federation.set-trust')
|
||||
})
|
||||
|
||||
it('federationSetTrust omits password on demotion', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'observer' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationSetTrust forwards the password when promoting', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({
|
||||
did: 'did:key:z',
|
||||
trust_level: 'trusted',
|
||||
password: 'hunter2',
|
||||
})
|
||||
})
|
||||
|
||||
it('federationSyncState calls federation.sync-state', async () => {
|
||||
mockSuccess({ synced: 1, failed: 0, results: [] })
|
||||
await rpcClient.federationSyncState()
|
||||
|
||||
@@ -781,12 +781,18 @@ class RPCClient {
|
||||
}
|
||||
|
||||
// Federation
|
||||
/** Minting a `trusted` invite requires the node password — the backend
|
||||
* rejects it with a `PASSWORD_REQUIRED` error until one is supplied.
|
||||
* Observer invites never need one. */
|
||||
async federationInvite(
|
||||
trustLevel: 'trusted' | 'observer' = 'trusted'
|
||||
trustLevel: 'trusted' | 'observer' = 'trusted',
|
||||
password?: string,
|
||||
): Promise<{ code: string; did: string; onion: string; trust_level: string }> {
|
||||
const params: Record<string, unknown> = { trust_level: trustLevel }
|
||||
if (password) params.password = password
|
||||
return this.call({
|
||||
method: 'federation.invite',
|
||||
params: { trust_level: trustLevel },
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -855,13 +861,19 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
/** Promotion TO `trusted` requires the node password — the backend rejects
|
||||
* it with a `PASSWORD_REQUIRED` error until one is supplied. Demotion is
|
||||
* never gated: making a peer less privileged must stay easy. */
|
||||
async federationSetTrust(
|
||||
did: string,
|
||||
trustLevel: 'trusted' | 'observer' | 'untrusted',
|
||||
password?: string,
|
||||
): Promise<{ updated: boolean; did: string; trust_level: string }> {
|
||||
const params: Record<string, unknown> = { did, trust_level: trustLevel }
|
||||
if (password) params.password = password
|
||||
return this.call({
|
||||
method: 'federation.set-trust',
|
||||
params: { did, trust_level: trustLevel },
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,15 @@
|
||||
@confirm="confirmPresenceSign"
|
||||
@cancel="showPresenceSignModal = false"
|
||||
/>
|
||||
|
||||
<TrustPasswordModal
|
||||
:visible="showTrustPassword"
|
||||
:context="trustPasswordContext"
|
||||
:busy="trustPasswordBusy"
|
||||
:error="trustPasswordError"
|
||||
@confirm="submitTrustPassword"
|
||||
@close="closeTrustPassword"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -243,9 +252,10 @@ import JoinModal from './federation/JoinModal.vue'
|
||||
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
|
||||
import DiscoverModal from './federation/DiscoverModal.vue'
|
||||
import PresenceSignModal from './federation/PresenceSignModal.vue'
|
||||
import TrustPasswordModal from './federation/TrustPasswordModal.vue'
|
||||
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
import { nodeName, timeAgo } from './federation/utils'
|
||||
import { nodeName, nodeNameFromDid, timeAgo } from './federation/utils'
|
||||
|
||||
const transportStore = useTransportStore()
|
||||
const appStore = useAppStore()
|
||||
@@ -529,15 +539,73 @@ function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||
generateInvite()
|
||||
}
|
||||
|
||||
/** The backend is the only authority on whether a given change is an
|
||||
* escalation, so the UI never pre-judges: it attempts the call and prompts
|
||||
* only when the backend says a password is required. That keeps demotions —
|
||||
* and no-op re-sets of an already-Trusted peer — free of a pointless prompt
|
||||
* without the frontend having to duplicate the rule. */
|
||||
function isPasswordRequired(e: unknown): boolean {
|
||||
return e instanceof Error && e.message.includes('PASSWORD_REQUIRED')
|
||||
}
|
||||
|
||||
const showTrustPassword = ref(false)
|
||||
const trustPasswordContext = ref('')
|
||||
const trustPasswordBusy = ref(false)
|
||||
const trustPasswordError = ref('')
|
||||
let pendingTrustAction: ((password: string) => Promise<void>) | null = null
|
||||
|
||||
function promptForTrustPassword(context: string, action: (password: string) => Promise<void>) {
|
||||
trustPasswordContext.value = context
|
||||
trustPasswordError.value = ''
|
||||
pendingTrustAction = action
|
||||
showTrustPassword.value = true
|
||||
}
|
||||
|
||||
function closeTrustPassword() {
|
||||
showTrustPassword.value = false
|
||||
trustPasswordError.value = ''
|
||||
trustPasswordBusy.value = false
|
||||
pendingTrustAction = null
|
||||
}
|
||||
|
||||
async function submitTrustPassword(password: string) {
|
||||
if (!pendingTrustAction) return
|
||||
try {
|
||||
trustPasswordBusy.value = true
|
||||
trustPasswordError.value = ''
|
||||
await pendingTrustAction(password)
|
||||
closeTrustPassword()
|
||||
} catch (e) {
|
||||
// Keep the failure inside the modal so the operator can retry in place
|
||||
// rather than losing the pending action to the page-level banner.
|
||||
trustPasswordError.value = e instanceof Error ? e.message : 'Password verification failed'
|
||||
} finally {
|
||||
trustPasswordBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws so both the first attempt and the password retry can
|
||||
* route the error to the right place. */
|
||||
async function requestInvite(password?: string) {
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value, password)
|
||||
inviteCode.value = result.code
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
generatingInvite.value = true
|
||||
error.value = ''
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value)
|
||||
inviteCode.value = result.code
|
||||
await requestInvite()
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
promptForTrustPassword(
|
||||
'This invite grants Trusted access to whoever redeems it — full read of this node\'s state, and the ability to deploy apps to it. Confirm with your node password.',
|
||||
requestInvite,
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
@@ -578,14 +646,27 @@ async function syncAll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws; see `requestInvite`. */
|
||||
async function requestTrustChange(did: string, level: string, password?: string) {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted', password)
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTrust(did: string, level: string) {
|
||||
try {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
await requestTrustChange(did, level)
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
const name = nodeNameFromDid(did, nodes.value)
|
||||
promptForTrustPassword(
|
||||
`Granting ${name} Trusted lets it read this node's state and deploy apps to it. Confirm with your node password.`,
|
||||
(password) => requestTrustChange(did, level, password),
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<select
|
||||
:value="node.trust_level"
|
||||
@change="emit('change-trust', node!.did, ($event.target as HTMLSelectElement).value)"
|
||||
@change="onTrustChange"
|
||||
class="bg-black/30 text-white text-sm rounded px-2 py-1 border border-white/10"
|
||||
>
|
||||
<option value="trusted">Trusted</option>
|
||||
@@ -34,6 +34,9 @@
|
||||
<option value="untrusted">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
<span class="text-white/30">Granted via:</span> {{ trustSourceLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg p-3">
|
||||
<p class="text-xs text-white/40 mb-1">Added</p>
|
||||
@@ -130,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FederatedNode } from './types'
|
||||
import { formatBytes, formatUptime } from './utils'
|
||||
|
||||
@@ -156,6 +159,32 @@ const emit = defineEmits<{
|
||||
const confirmRemove = ref(false)
|
||||
const deployAppId = ref('')
|
||||
|
||||
const TRUST_SOURCE_LABELS: Record<string, string> = {
|
||||
invite: 'An invite you minted',
|
||||
'uninvited-join': 'Joined without an invite — capped at Observer',
|
||||
'transitive-merge': 'Advertised by another peer — capped at Observer',
|
||||
manual: 'You set it here',
|
||||
}
|
||||
|
||||
/** Unknown provenance is stated plainly rather than hidden: a peer recorded
|
||||
* before this was tracked is precisely the one worth a second look. */
|
||||
const trustSourceLabel = computed(
|
||||
() => TRUST_SOURCE_LABELS[props.node?.trust_source ?? ''] ?? 'Unknown — recorded before this was tracked',
|
||||
)
|
||||
|
||||
/** Snap the select back to the node's actual level immediately. Promoting to
|
||||
* Trusted asks for the node password, and the operator may cancel or get it
|
||||
* wrong — without this the dropdown would keep displaying a level the node
|
||||
* never accepted. On success the parent reloads and the prop drives the new
|
||||
* value back in. */
|
||||
function onTrustChange(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const level = select.value
|
||||
if (!props.node) return
|
||||
select.value = props.node.trust_level
|
||||
emit('change-trust', props.node.did, level)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
confirmRemove.value = false
|
||||
deployAppId.value = ''
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="handleClose">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Confirm Trusted Access</h3>
|
||||
<p class="text-sm text-white/60 mb-4">{{ context }}</p>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter your node password to confirm"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<p v-if="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="busy || !password"
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
||||
>
|
||||
{{ busy ? 'Verifying…' : 'Grant Trusted' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** What is about to be granted, in the operator's terms. */
|
||||
context: string
|
||||
busy: boolean
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [password: string]
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const passwordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function submit() {
|
||||
if (!password.value || props.busy) return
|
||||
emit('confirm', password.value)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
password.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Never leave the password sitting in memory once the modal is dismissed,
|
||||
// and put the cursor where the operator has to type anyway.
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (!val) {
|
||||
password.value = ''
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
passwordInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
@@ -40,6 +40,13 @@ export interface FederatedNode {
|
||||
last_sync_error?: string
|
||||
/** RFC 3339 timestamp of last_sync_error. */
|
||||
last_sync_error_at?: string
|
||||
/**
|
||||
* How this peer's trust level came to be what it is. `null` means it was
|
||||
* recorded before provenance was tracked — which is exactly the population
|
||||
* worth reviewing, since it may include grants made by the fail-open paths
|
||||
* that `uninvited-join` / `transitive-merge` now cap at Observer.
|
||||
*/
|
||||
trust_source?: 'invite' | 'uninvited-join' | 'transitive-merge' | 'manual' | null
|
||||
}
|
||||
|
||||
export interface DwnStatus {
|
||||
|
||||
Reference in New Issue
Block a user