@dotrino/vaultd 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/src/admin.js CHANGED
@@ -127,13 +127,22 @@ export function createAdminDesk ({
127
127
  return { ok: true, result: { ok: true } }
128
128
  }
129
129
 
130
- // Quitar un dispositivo: por `sub` se le retiran TODOS sus certificados. Con
131
- // `certNonce` a secas solo cae ese papel, y el aparato puede tener otro vigente.
130
+ // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
131
+ // todos los certificados. Las dos cosas o ninguna.
132
+ //
133
+ // `certNonce` retira UN PAPEL y nada más — el aparato sigue siendo miembro. Sirve
134
+ // para eso y solo para eso, y se conserva por compatibilidad, pero no es «quitar»:
135
+ // usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
136
+ // mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
137
+ // significa «renueva»). Ese era el dispositivo fantasma.
132
138
  if (data.op === 'revoke') {
133
139
  if (data.sub) {
140
+ const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
134
141
  const r = await desk.revokeDevice(String(data.sub))
135
- audit('admin.revoke-device', { by, certs: r?.nonces?.length ?? null })
136
- await notify('revoked', { by })
142
+ audit('admin.revoke-device', { by, device: deviceId, certs: r?.nonces?.length ?? null })
143
+ // Con el `deviceId`: el aviso a los demás dispositivos tiene que decir a QUIÉN
144
+ // quitaron, o no se puede saber si el que sobra eres tú.
145
+ await notify('revoked', { deviceId, by })
137
146
  return { ok: true, result: r || { ok: true } }
138
147
  }
139
148
  const r = await desk.revoke(String(data.certNonce || ''))
package/lib/src/env.js CHANGED
@@ -172,7 +172,7 @@ export function applyEnv (secrets, override = overrideByDefault()) {
172
172
  * @param {Object} [opts]
173
173
  * @param {string} [opts.ns]
174
174
  * @param {string} [opts.dir]
175
- * @param {(info:{ns:string, ts:number, motivo:'cambio'|'revocado'})=>void} [opts.onUpdate]
175
+ * @param {(info:{ns:string, ts:number, reason:'changed'|'revoked'})=>void} [opts.onUpdate]
176
176
  * Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
177
177
  * opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
178
178
  * @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
@@ -183,24 +183,24 @@ export function applyEnv (secrets, override = overrideByDefault()) {
183
183
  export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...resto } = {}) {
184
184
  ns = resolveNs(ns)
185
185
  dir = dir || serviceDir(ns)
186
- const decir = (m) => { if (!quiet) console.error(m) }
186
+ const say = (m) => { if (!quiet) console.error(m) }
187
187
 
188
- const salir = (motivo) => {
189
- decir(`[dotrino-env] ${motivo === 'revocado'
188
+ const exitNow = (reason) => {
189
+ say(`[dotrino-env] ${reason === 'revoked'
190
190
  ? 'la bóveda REVOCÓ este agente: terminando (no volverá a arrancar)'
191
191
  : 'configuración nueva en la bóveda: terminando para que el supervisor lo levante limpio'}`)
192
- process.exit(motivo === 'revocado' ? 1 : exitCode)
192
+ process.exit(reason === 'revoked' ? 1 : exitCode)
193
193
  }
194
194
 
195
195
  return watchSecretsChanges({
196
196
  dir,
197
197
  ns,
198
- log: decir,
199
- onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, motivo: 'cambio' }) : salir('cambio')),
198
+ log: say,
199
+ onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed' }) : exitNow('changed')),
200
200
  // Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
201
201
  // levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
202
202
  // reinicios fallidos es lo que hace que se note en vez de girar en silencio.
203
- onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), motivo: 'revocado' }) : salir('revocado')),
203
+ onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), reason: 'revoked' }) : exitNow('revoked')),
204
204
  ...resto
205
205
  })
206
206
  }
package/lib/src/index.js CHANGED
@@ -206,8 +206,14 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
206
206
  reject: (deviceId) => desk.reject(deviceId),
207
207
  listPending: desk.listPending,
208
208
  listMachines,
209
- // Revoca y AVISA a la máquina con un REVOKED firmado para que se auto-borre (ahora si
210
- // está online, o al reaparecer vía handleDevices).
209
+ // QUITA LA MÁQUINA entera (por su llave): fuera del acta y sin ningún certificado
210
+ // vigente, que son las dos caras del mismo acto. Y AVISA con un REVOKED firmado para
211
+ // que se auto-borre (ahora si está online, o al reaparecer vía handleDevices).
212
+ revokeDevice: (sub) => desk.revokeDevice(sub),
213
+ // Retira UN certificado. No es quitar la máquina: sigue siendo miembro del acta, y
214
+ // quien queda así ya no recibe el aviso de expulsión (mientras siga en el acta, un
215
+ // papel retirado significa «renueva»). Usar `revokeDevice` salvo que quieras
216
+ // exactamente esto.
211
217
  revoke: (nonce) => desk.revoke(nonce),
212
218
  getSelfCert,
213
219
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
@@ -0,0 +1,67 @@
1
+ /**
2
+ * revocation.js — CUÁNDO se le dice a un aparato que ya no es de casa.
3
+ *
4
+ * Módulo PURO (sin red, sin disco, sin `node:*`), como `enroll.js` y `admin.js`: la regla
5
+ * vive en un solo sitio, se lee entera y se prueba sin levantar nada.
6
+ *
7
+ * EL PROBLEMA QUE RESUELVE. Un dispositivo al que quitaron del perfil no se entera por su
8
+ * cuenta: lo único que le borra la cuenta es un aviso FIRMADO por la maestra
9
+ * (`vault.revoked`). Un «unauthorized» suelto no vale y no debe valer — no va firmado, así
10
+ * que cualquiera podría destruir datos ajenos con un mensaje (el wipe-DoS de
11
+ * `docs/pairing-protocol.md §2.3`). Por eso la bóveda tiene que **atender** la conexión de
12
+ * un aparato que fue suyo, aunque su papel ya no sirva, precisamente para poder mandarlo a
13
+ * paseo. Si no lo hace, el aparato se queda enseñando una cuenta que ya no existe.
14
+ *
15
+ * LA REGLA, en una línea: papel nuestro + ya no está en el acta ⇒ se le avisa.
16
+ */
17
+
18
+ /**
19
+ * Motivos de rechazo de `verifyChain` que significan «este papel ya no sirve».
20
+ *
21
+ * Los tres se producen DESPUÉS de que `verifyChain` haya comprobado la firma del propio
22
+ * aparato y la del certificado, así que quien llega hasta aquí demostró tener la llave que
23
+ * el certificado nombra. El resto de motivos (`shape`, `bad-signature`,
24
+ * `bad-action-signature`, `cert-device-mismatch`, `untrusted-issuer`…) son ruido o gente
25
+ * ajena: ahí no hay a quién avisar de nada.
26
+ */
27
+ export const STALE_PAPER = Object.freeze(['revoked', 'expired', 'scope'])
28
+ const STALE = new Set(STALE_PAPER)
29
+
30
+ /** ¿El motivo del rechazo es «tu papel ya no sirve»? */
31
+ export const isStalePaper = (reason) => STALE.has(reason)
32
+
33
+ /**
34
+ * ¿Hay que mandarle el aviso firmado de expulsión?
35
+ *
36
+ * @param {Object} o
37
+ * @param {string} o.reason por qué falló `verifyChain`.
38
+ * @param {string} o.pubkey la llave del aparato que acaba de escribir.
39
+ * @param {string} o.master la maestra de ESTA bóveda.
40
+ * @param {string|null} [o.certIss] quién firmó el certificado que presentó.
41
+ * @param {Array<{pub:string}>|null} [o.members] miembros del acta; `null` = no hay acta.
42
+ * @param {boolean} [o.knownRevoked] consta explícitamente como revocado en las
43
+ * delegaciones. Es el único criterio que queda cuando no hay acta.
44
+ * @returns {boolean}
45
+ */
46
+ export function shouldNotifyRevoked ({ reason, pubkey, master, certIss = null, members = null, knownRevoked = false } = {}) {
47
+ if (typeof pubkey !== 'string' || !pubkey) return false
48
+ if (!isStalePaper(reason)) return false
49
+ // Que el papel sea NUESTRO. No hace falta que siga siendo válido —justo por eso estamos
50
+ // aquí— pero sí que lo haya firmado esta maestra: es lo que separa a un aparato que fue
51
+ // de casa de uno que pasaba por ahí.
52
+ if (certIss && master && certIss !== master) return false
53
+ // El aparato no puede echarse a sí mismo, y la maestra tampoco se echa.
54
+ if (master && pubkey === master) return false
55
+
56
+ // EL ACTA MANDA, y es lo único que manda. Un certificado retirado o vencido NO significa
57
+ // «estás fuera»: renovar retira el anterior y cambiar permisos obliga a renovar, así que
58
+ // un aparato de casa con un papel viejo solo tiene que renovar. Decirle «estás fuera»
59
+ // ahí lo borraba solo: dabas «administra» y el dispositivo desaparecía.
60
+ if (Array.isArray(members)) return !members.some((m) => m?.pub === pubkey)
61
+
62
+ // Sin acta (bóveda anterior al acta) no se puede saber quién es del perfil: solo se
63
+ // avisa a quien conste explícitamente como revocado.
64
+ return !!knownRevoked
65
+ }
66
+
67
+ export default { STALE_PAPER, isStalePaper, shouldNotifyRevoked }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=20"
20
20
  },
21
21
  "dependencies": {
22
- "@dotrino/identity": "^0.45.0",
22
+ "@dotrino/identity": "^0.47.0",
23
23
  "@dotrino/proxy-client": "^0.10.0",
24
24
  "ws": "^8.18.0"
25
25
  },
package/src/tui/app.js CHANGED
@@ -88,6 +88,36 @@ function groupByDevice (issued) {
88
88
  return [...by.values()]
89
89
  }
90
90
 
91
+ /**
92
+ * LA lista de dispositivos: el acta, con el certificado de cada uno pegado.
93
+ *
94
+ * El acta es quien dice de quién es el perfil; los certificados son el reflejo de esa
95
+ * decisión y pueden faltar (retirados, vencidos). Un miembro sin certificado sale igual,
96
+ * marcado como «sin acceso», porque es exactamente el que hay que poder quitar.
97
+ *
98
+ * Si todavía no hay acta (bóveda anterior al acta, o sin volcar), se cae a los
99
+ * certificados: peor lista, pero lista.
100
+ */
101
+ function mergeMembersAndCerts (members, issued) {
102
+ const certs = new Map()
103
+ for (const d of groupByDevice(issued)) certs.set(d.sub || d.deviceId || d.nonce, d)
104
+ if (!Array.isArray(members) || !members.length) return [...certs.values()]
105
+ return members.map((m) => {
106
+ const cert = certs.get(m.pub)
107
+ return {
108
+ ...(cert || {}),
109
+ sub: m.pub,
110
+ deviceId: m.id || cert?.deviceId || '????-????',
111
+ label: m.label || cert?.label || '',
112
+ isMaster: !!m.isMaster,
113
+ cn: m.cn || null,
114
+ // El master es la propia bóveda: no tiene (ni necesita) certificado. Un servicio
115
+ // tampoco lleva uno de dispositivo. Marcarlos «sin acceso» sería una alarma falsa.
116
+ noAccess: !cert && !m.isMaster && !m.cn
117
+ }
118
+ })
119
+ }
120
+
91
121
  function activeProfile (st) {
92
122
  const list = st.profiles?.profiles || []
93
123
  return list.find((p) => p.current) || list[0] || null
@@ -193,15 +223,27 @@ function deviceRows (st, t) {
193
223
  // UNA FILA POR APARATO, no por certificado. Antes se pintaba `issued` tal cual y un
194
224
  // aparato con dos certs (el viejo + el de la renovación) salía dos veces: parecían dos
195
225
  // máquinas. Se agrupa por llave y se muestra el cert vigente más largo.
196
- const issued = groupByDevice(st.devices?.issued || [])
197
- if (!issued.length) {
226
+ //
227
+ // Y la lista sale del ACTA, no de los certificados: el acta dice quién es del perfil, y
228
+ // los certificados son su reflejo. Un miembro sin certificado vigente —porque le
229
+ // retiraron el papel pero no lo sacaron del acta, o porque se le venció— no salía en
230
+ // ninguna pantalla del PC: invisible aquí, presente en la del navegador, y sin forma de
231
+ // quitarlo más que adivinando su ID para el `revoke` de la línea de comandos.
232
+ const devices = mergeMembersAndCerts(st.members, st.devices?.issued || [])
233
+ if (!devices.length) {
198
234
  rows.push({ text: t.muted(i.noDevices), sel: false })
199
235
  }
200
- for (const d of issued) {
236
+ for (const d of devices) {
201
237
  const label = d.label || t.muted(i.noLabel)
202
238
  const extra = d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : ''
203
- const line = ` ${t.bold(d.deviceId)} ${label} ${t.muted('scope:' + shortScope(d.scope))} ${t.muted('exp:' + fmtExp(d.exp))}${extra}`
204
- rows.push({ text: line, sel: true, meta: d })
239
+ // SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
240
+ // en el color de aviso y en el sitio donde estaría su vencimiento.
241
+ const estado = d.noAccess
242
+ ? t.warn(i.deviceNoAccess)
243
+ : d.isMaster
244
+ ? t.muted(i.thisVault)
245
+ : t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
246
+ rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${estado}${extra}`, sel: true, meta: d })
205
247
  }
206
248
  const revoked = st.devices?.revoked || []
207
249
  if (revoked.length) {
@@ -342,9 +384,12 @@ async function guard (term, st, msg, fn) {
342
384
  async function refreshAll (term, st) {
343
385
  const r = await guard(term, st, L(st).loading, () => vc.snapshot(activeId(st)))
344
386
  if (!r.ok) return
345
- const { devices, secrets, profiles } = r.v
387
+ const { devices, secrets, profiles, acta } = r.v
346
388
  if (profiles) st.profiles = profiles
347
389
  if (secrets) st.secrets = secrets.ns || {}
390
+ // El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
391
+ // `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
392
+ if (acta) st.members = acta.members || []
348
393
  if (devices) {
349
394
  const issued = (devices.issued || devices.active || devices.delegations || [])
350
395
  st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await vc.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
@@ -353,7 +398,11 @@ async function refreshAll (term, st) {
353
398
 
354
399
  async function refreshDevices (term, st) {
355
400
  const r = await guard(term, st, L(st).loadingDevices, () => vc.listDevices(activeId(st)))
356
- if (r.ok) st.devices = r.v
401
+ if (!r.ok) return
402
+ st.devices = r.v
403
+ // La lista se pinta desde el acta; traerla aparte dejaba la pantalla con los miembros de
404
+ // hace dos operaciones (quitar uno no lo quitaba de la vista).
405
+ if (Array.isArray(r.v.members)) st.members = r.v.members
357
406
  }
358
407
  async function refreshSecrets (term, st) {
359
408
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
@@ -545,6 +594,10 @@ async function onKeyDevices (term, st, key) {
545
594
  if (!st.pending) { flash(st, i.noPendingToReject, 'warn'); return true }
546
595
  const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
547
596
  if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
597
+ } else if ((ch === 'v' || key.name === 'delete') && cur?.isMaster) {
598
+ // La bóveda no se echa a sí misma: el acta la necesita para poder sellarse. Se dice
599
+ // en vez de mandar la orden y enseñar el error del daemon, que no explica nada.
600
+ flash(st, i.cantRemoveMaster, 'warn')
548
601
  } else if ((ch === 'v' || key.name === 'delete') && (cur?.sub || cur?.nonce != null)) { // quitar el aparato seleccionado
549
602
  setConfirm(st, {
550
603
  text: i.revokeConfirm(cur.deviceId),
@@ -1111,4 +1164,4 @@ export async function runTui () {
1111
1164
  }
1112
1165
 
1113
1166
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
1114
- export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
1167
+ export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
package/src/tui/i18n.js CHANGED
@@ -128,13 +128,17 @@ const es = {
128
128
  pendingHint: ' — pulsa A para aprobar, X para rechazar',
129
129
  noDevices: ' (sin dispositivos enrolados — pulsa P para emparejar uno)',
130
130
  noLabel: '(sin etiqueta)',
131
+ // Está en el acta y no puede entrar: o le retiraron el certificado, o se le venció.
132
+ deviceNoAccess: 'SIN ACCESO — está en el acta pero no puede entrar',
133
+ thisVault: 'esta bóveda (manda ella)',
134
+ cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
131
135
  revokedCount: (n) => ` Revocados: ${n}`,
132
136
  startingPairing: 'Iniciando emparejamiento…',
133
137
  noPending: 'No hay ningún dispositivo pendiente',
134
138
  noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
135
139
  rejecting: 'Rechazando…',
136
140
  deviceRejected: 'Dispositivo rechazado',
137
- revokeConfirm: (id) => `¿Revocar ${id}? Se le ordena autoborrarse al reconectar.`,
141
+ revokeConfirm: (id) => `¿Quitar ${id} del perfil? Sale del acta, se le retiran los certificados y se le ordena autoborrarse al reconectar.`,
138
142
  revoking: 'Revocando…',
139
143
  deviceRevoked: (id) => `Revocado ${id}`,
140
144
  approveLabel: (id) => `Código que MUESTRA el dispositivo ${id}`,
@@ -351,13 +355,16 @@ const en = {
351
355
  pendingHint: ' — press A to approve, X to reject',
352
356
  noDevices: ' (no devices enrolled — press P to pair one)',
353
357
  noLabel: '(no label)',
358
+ deviceNoAccess: 'NO ACCESS — it is on the record but cannot get in',
359
+ thisVault: 'this vault (it is the Master)',
360
+ cantRemoveMaster: 'This vault is the Master: it does not remove itself.',
354
361
  revokedCount: (n) => ` Revoked: ${n}`,
355
362
  startingPairing: 'Starting pairing…',
356
363
  noPending: 'No device is waiting',
357
364
  noPendingToReject: 'No device is waiting to be rejected',
358
365
  rejecting: 'Rejecting…',
359
366
  deviceRejected: 'Device rejected',
360
- revokeConfirm: (id) => `Revoke ${id}? It is told to erase itself on reconnect.`,
367
+ revokeConfirm: (id) => `Remove ${id} from the profile? It leaves the record, its certificates are withdrawn and it is told to erase itself on reconnect.`,
361
368
  revoking: 'Revoking…',
362
369
  deviceRevoked: (id) => `Revoked ${id}`,
363
370
  approveLabel: (id) => `Code SHOWN by device ${id}`,
package/src/vault.js CHANGED
@@ -17,6 +17,7 @@ import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
17
17
  import * as Acta from '@dotrino/identity/acta'
18
18
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
19
19
  import { createAdminDesk } from '../lib/src/admin.js'
20
+ import { shouldNotifyRevoked } from '../lib/src/revocation.js'
20
21
  import { createTransport, masterPubkeyOf } from './transport.js'
21
22
  import { openStore } from './store.js'
22
23
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
@@ -145,7 +146,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
145
146
  data: p.data, signature: p.signature, cert: p.cert,
146
147
  expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
147
148
  })
148
- if (!chk.ok) { audit('rejected', { what: 'sign', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
149
+ if (!chk.ok) return denyChain(from, chk, p, 'sign')
149
150
  const toSign = p.data?.payload
150
151
  if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload required' })
151
152
  const { signature, publickey } = await identity.signData(toSign)
@@ -159,7 +160,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
159
160
  data: p.data, signature: p.signature, cert: p.cert,
160
161
  expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
161
162
  })
162
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
163
+ if (!chk.ok) return denyChain(from, chk, p, 'get')
163
164
  const id = p.data?.id || 'root'
164
165
  reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
165
166
  }
@@ -187,7 +188,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
187
188
  if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
188
189
  chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
189
190
  }
190
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
191
+ if (!chk.ok) return denyChain(from, chk, p, 'store')
191
192
  try {
192
193
  // CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
193
194
  // pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
@@ -227,30 +228,55 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
227
228
  * — y hasta ahora el daemon no lo reemitía nunca. Si el aparato estaba apagado cuando lo
228
229
  * quitaste, no se enteraba jamás: seguía enseñando el perfil como si nada.
229
230
  */
230
- async function avisarSiRevocado (pubkey) {
231
+ async function notifyIfRevoked (pubkey, nonce = null, certIss = null, reason = 'revoked') {
231
232
  if (typeof pubkey !== 'string') return
232
233
  try {
233
- // OJO con dónde se buscan: desde identity 0.42 `issued` es «lo que HOY sirve para
234
- // entrar», así que los retirados NO están ahí viven en `revokedCerts`. Buscarlos
235
- // en `issued` no daba error, daba una lista vacía y ningún aviso.
236
- const dele = await identity.listDelegations()
237
- const revocados = await revocationSet()
238
- const candidatos = [...(dele.revokedCerts || []), ...(dele.issued || [])]
239
- const suyas = candidatos.filter((x) => x.sub === pubkey && (x.revokedAt || revocados.has(x.nonce)))
240
- if (suyas.length) {
241
- await desk.emitRevoke(pubkey, suyas[0].nonce)
242
- audit('revoke.notified', { device: await deviceIdOf(pubkey).catch(() => null) })
234
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
235
+ // Sin acta (bóveda anterior al acta) hay que mirar las delegaciones, que es lo único
236
+ // que queda. OJO con dónde se buscan: desde identity 0.42 `issued` es «lo que HOY
237
+ // sirve para entrar», así que los retirados NO están ahí — viven en `revokedCerts`.
238
+ let knownRevoked = false
239
+ let fallbackNonce = null
240
+ if (!record) {
241
+ const delegations = await identity.listDelegations()
242
+ const revoked = await revocationSet()
243
+ const candidates = [...(delegations.revokedCerts || []), ...(delegations.issued || [])]
244
+ const theirs = candidates.filter((x) => x.sub === pubkey && (x.revokedAt || revoked.has(x.nonce)))
245
+ knownRevoked = theirs.length > 0
246
+ fallbackNonce = theirs[0]?.nonce || null
243
247
  }
248
+ if (!shouldNotifyRevoked({ reason, pubkey, master, certIss, members: record?.members || null, knownRevoked })) return
249
+ // El aviso nombra el certificado que el aparato ACABA de presentar: es el que tiene
250
+ // en la mano, y es contra ese contra el que comprueba antes de borrarse.
251
+ await desk.emitRevoke(pubkey, nonce || fallbackNonce)
252
+ audit('revoke.notified', { device: await deviceIdOf(pubkey).catch(() => null), reason })
244
253
  } catch (e) { log('[vault] could not re-emit the revocation:', e.message) }
245
254
  }
246
255
 
256
+ /**
257
+ * Rechaza una petición y, si el aparato ya no es del perfil, SE LO DICE con el aviso
258
+ * firmado — sea cual sea la operación que estuviera intentando.
259
+ *
260
+ * Un dispositivo que fue tuyo tiene que poder llegar hasta aquí precisamente para que se
261
+ * le pueda mandar a paseo: es el único mensaje que le borra la cuenta, porque es el único
262
+ * que va firmado por la maestra (un «unauthorized» suelto no borra nada y no debe: sería
263
+ * destruir datos ajenos con un mensaje, el wipe-DoS de `docs/pairing-protocol.md §2.3`).
264
+ *
265
+ * Antes esto solo pasaba en `devices` —la pantalla de dispositivos— y solo si el papel
266
+ * estaba REVOCADO. Un aparato quitado que estuviera guardando notas nunca preguntaba por
267
+ * ahí, y uno al que simplemente se le venció el certificado no entraba en el caso: los
268
+ * dos se quedaban enseñando la cuenta indefinidamente.
269
+ */
270
+ async function denyChain (from, chk, p, what) {
271
+ await notifyIfRevoked(p.data?.publickey, p.cert?.nonce || null, p.cert?.iss || null, chk.reason)
272
+ if (what) audit('rejected', { what, reason: chk.reason })
273
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
274
+ }
275
+
247
276
  async function handleDevices (from, p) {
248
277
  if (!isFresh(p.data)) return staleReply(from)
249
278
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
250
- if (!chk.ok) {
251
- if (chk.reason === 'revoked') await avisarSiRevocado(p.data?.publickey)
252
- return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
253
- }
279
+ if (!chk.ok) return denyChain(from, chk, p, null)
254
280
  const { issued, revoked } = await identity.listDelegations()
255
281
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
256
282
  // política (quién manda, quién puede qué) sin un canal aparte.
@@ -286,7 +312,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
286
312
  async function handleRenew (from, p) {
287
313
  if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
288
314
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
289
- if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
315
+ if (!chk.ok) return denyChain(from, chk, p, 'renew')
290
316
  // Reusar el label del cert original (si sigue registrado en delegations).
291
317
  const { issued } = await identity.listDelegations()
292
318
  const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
@@ -327,7 +353,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
327
353
  data: p.data, signature: p.signature, cert: p.cert,
328
354
  expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
329
355
  })
330
- if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
356
+ if (!chk.ok) return denyChain(from, chk, p, 'secrets')
331
357
  // FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
332
358
  // miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
333
359
  // llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
@@ -480,7 +506,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
480
506
  data, signature, cert,
481
507
  expectedScope: SCOPE.ADMIN, trustedIssuer: master, revoked: await revocationSet()
482
508
  })
483
- if (!chk.ok) return chk
509
+ // Quitarse a UNO MISMO desde la consola remota entra por aquí: la segunda petición
510
+ // que mande el aparato ya llega con el certificado retirado. Que se entere con el
511
+ // aviso firmado, en vez de con un error suelto que no puede borrar nada.
512
+ if (!chk.ok) {
513
+ await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
514
+ return chk
515
+ }
484
516
  const acta = (await identity.profileActa?.().catch(() => null))?.acta
485
517
  if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
486
518
  return chk
@@ -179,13 +179,17 @@ export const removeProfilePassword = (profile) => profileOp('password-rm', { pro
179
179
  */
180
180
  export async function snapshot (profile) {
181
181
  requireAlive()
182
- rm(F.devices); rm(F.secretsList); rm(F.profilesList)
182
+ rm(F.devices); rm(F.secretsList); rm(F.profilesList); rm(F.acta)
183
183
  writeReq(F.dumpReq, {}, profile)
184
184
  signalOrCleanup('SIGUSR2', [F.dumpReq])
185
- const [devices, secrets, profiles] = await Promise.all([
186
- waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList)
185
+ // El ACTA entra en el volcado normal: es la lista de dispositivos de verdad, y las
186
+ // delegaciones son su reflejo. Sin ella, un miembro sin certificados (revocado a medias,
187
+ // o con el papel caducado) no salía en ninguna pantalla del PC — invisible y, por lo
188
+ // tanto, imposible de quitar desde aquí.
189
+ const [devices, secrets, profiles, acta] = await Promise.all([
190
+ waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList), waitFor(F.acta)
187
191
  ])
188
- return { devices, secrets, profiles }
192
+ return { devices, secrets, profiles, acta }
189
193
  }
190
194
 
191
195
  /**
@@ -193,14 +197,22 @@ export async function snapshot (profile) {
193
197
  * `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
194
198
  */
195
199
  export async function listDevices (profile) {
196
- const { devices } = await snapshot(profile)
200
+ const { devices, acta } = await snapshot(profile)
197
201
  if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
198
202
  const issued = devices.issued || devices.active || devices.delegations || []
199
203
  const revoked = devices.revoked || []
200
204
  const withIds = await Promise.all(issued.map(async (d) => ({
201
205
  ...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
202
206
  })))
203
- return { issued: agruparPorAparato(withIds, revoked), revoked, profile: devices.profile || null }
207
+ // Los MIEMBROS viajan con la lista: quién es del perfil lo dice el acta, y los
208
+ // certificados son su reflejo. Quien pinte la lista los necesita a la vez, o acaba
209
+ // enseñando solo a los que tienen papel — y el que hay que quitar es justo el que no.
210
+ return {
211
+ issued: agruparPorAparato(withIds, revoked),
212
+ revoked,
213
+ members: acta?.members || [],
214
+ profile: devices.profile || null
215
+ }
204
216
  }
205
217
 
206
218
  /**