feat(security): require the node password to grant Trusted
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:
archipelago
2026-08-03 13:07:21 -04:00
co-authored by Claude Opus 5
parent f0b71f86aa
commit 24ce8b39e8
10 changed files with 587 additions and 32 deletions
+91 -10
View File
@@ -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'
}
}