@dotrino/vaultd 0.22.0 → 0.24.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,7 +127,15 @@ 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
132
  if (data.op === 'revoke') {
133
+ if (data.sub) {
134
+ const r = await desk.revokeDevice(String(data.sub))
135
+ audit('admin.revoke-device', { by, certs: r?.nonces?.length ?? null })
136
+ await notify('revoked', { by })
137
+ return { ok: true, result: r || { ok: true } }
138
+ }
131
139
  const r = await desk.revoke(String(data.certNonce || ''))
132
140
  audit('admin.revoke', { by, certNonce: data.certNonce })
133
141
  await notify('revoked', { certNonce: data.certNonce, by })
package/lib/src/enroll.js CHANGED
@@ -441,9 +441,32 @@ export function createEnrollDesk ({
441
441
  return res
442
442
  }
443
443
 
444
+ /**
445
+ * QUITA EL DISPOSITIVO entero: retira TODOS sus certificados vigentes y le manda una
446
+ * sola orden de autoborrado. `revoke(nonce)` retira un papel, y un aparato puede tener
447
+ * varios (una renovación dejaba vivo el anterior): quitarle uno no lo echaba, y podía
448
+ * quedarse dentro justo con el que llevaba `vault:admin`.
449
+ */
450
+ async function revokeDevice (sub) {
451
+ if (!sub) throw new Error('sub (device pubkey) required')
452
+ const { issued } = await identity.listDelegations()
453
+ const mine = (issued || []).filter((d) => d.sub === sub)
454
+ audit('revoke-device', { certs: mine.length })
455
+ // Si el núcleo no trae `revokeDevice` (bóveda vieja), se cae a retirarlos uno a uno.
456
+ const res = identity.revokeDevice
457
+ ? await identity.revokeDevice(sub)
458
+ : { ok: true, nonces: await (async () => {
459
+ const done = []
460
+ for (const d of mine) { await identity.revokeDelegation(d.nonce); done.push(d.nonce) }
461
+ return done
462
+ })() }
463
+ await emitRevoke(sub, mine[0]?.nonce || null)
464
+ return res
465
+ }
466
+
444
467
  return {
445
468
  startPairing, stopPairing, handleEnroll, handleActaSealed, handleHello, approve, reject,
446
- listPending, findPending, emitRevoke, revoke,
469
+ listPending, findPending, emitRevoke, revoke, revokeDevice,
447
470
  get pendingCount () { return pending.size }
448
471
  }
449
472
  }
package/lib/src/index.js CHANGED
@@ -155,14 +155,16 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
155
155
  if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
156
156
  const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
157
157
  if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
158
- const { issued, revoked } = await identity.listDelegations()
158
+ const { issued, revoked, revokedCerts } = await identity.listDelegations()
159
159
  const devices = await Promise.all((issued || []).map(async (x) => ({
160
160
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
161
161
  label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
162
162
  })))
163
163
  send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
164
164
  // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
165
- const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
165
+ // Se busca en `revokedCerts`: desde que `issued` solo trae lo vigente, los retirados
166
+ // ya no están ahí y este aviso dejaba de dispararse (se caía en silencio).
167
+ const mine = (revokedCerts || issued || []).find((x) => x.sub === chk.device && x.revokedAt)
166
168
  if (mine) desk.emitRevoke(chk.device, mine.nonce)
167
169
  }
168
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.22.0",
3
+ "version": "0.24.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.42.0",
22
+ "@dotrino/identity": "^0.44.0",
23
23
  "@dotrino/proxy-client": "^0.10.0",
24
24
  "ws": "^8.18.0"
25
25
  },
package/src/ctl.js CHANGED
@@ -410,13 +410,27 @@ async function cmdDevices () {
410
410
  let snap = null
411
411
  for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(devFile, null); if (d?.at) { snap = d; break } }
412
412
  if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
413
- const active = snap.issued || snap.active || snap.delegations || []
414
413
  const revoked = snap.revoked || []
414
+ const fuera = new Set(revoked.map((r) => r?.nonce || r))
415
+ // UN APARATO, UNA LÍNEA. El daemon lleva la cuenta por CERTIFICADO —correcto para él,
416
+ // porque revocar es revocar un papel—, pero renovar emite uno nuevo cada 30 días: un
417
+ // aparato de un año salía doce veces, y los ya retirados seguían contando como
418
+ // enrolados. Se agrupa por llave y se dice cuántos certificados tiene.
419
+ const porLlave = new Map()
420
+ for (const d of (snap.issued || snap.active || snap.delegations || [])) {
421
+ if (d.revokedAt || fuera.has(d.nonce)) continue
422
+ const clave = d.sub || d.nonce
423
+ const y = porLlave.get(clave)
424
+ if (!y) porLlave.set(clave, { ...d, certs: 1 })
425
+ else { y.certs++; if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, certs: y.certs }) }
426
+ }
427
+ const active = [...porLlave.values()]
415
428
  console.log('Dispositivos enrolados: %d', active.length)
416
429
  for (const d of active) {
417
430
  const did = d.sub ? await deviceIdOf(d.sub) : '????-????'
418
- console.log(' · %s %s%s%s', did, d.label || '(sin etiqueta)',
419
- d.exp ? ' exp=' + new Date(d.exp).toISOString() : '',
431
+ console.log(' · %s %s%s%s%s', did, d.label || '(sin etiqueta)',
432
+ d.exp ? ' vence=' + new Date(d.exp).toISOString().slice(0, 10) : '',
433
+ d.certs > 1 ? ' (' + d.certs + ' certificados)' : '',
420
434
  d.nonce ? ' nonce=' + d.nonce : '')
421
435
  }
422
436
  if (revoked.length) {
@@ -426,12 +440,29 @@ async function cmdDevices () {
426
440
  console.log('\nPara revocar uno (y ordenarle autoborrarse): dotrino-vault revoke <nonce>')
427
441
  }
428
442
 
429
- function cmdRevoke (nonce) {
430
- if (!nonce) { console.error('uso: dotrino-vault revoke <nonce>'); process.exit(2) }
443
+ /**
444
+ * `dotrino-vault revoke <ID|nonce>` — quita un dispositivo.
445
+ *
446
+ * Con el IDENTIFICADOR del aparato (`AB12-CD34`) se le retiran TODOS sus certificados,
447
+ * que es lo que la gente quiere decir con «quitar este dispositivo»: renovar emite uno
448
+ * nuevo cada 30 días, así que quitar solo el último dejaba vivos los anteriores hasta que
449
+ * caducaran — quitarlo sin quitarlo. Con un `nonce` suelto se retira ese y solo ese, que
450
+ * sigue siendo útil para casos finos.
451
+ */
452
+ async function cmdRevoke (arg) {
453
+ if (!arg) { console.error('uso: dotrino-vault revoke <ID|nonce> (el ID quita el aparato entero)'); process.exit(2) }
454
+ const esId = /^[0-9a-f]{4}-?[0-9a-f]{4}$/i.test(arg)
431
455
  const s = requireDaemon()
432
- writeReq('revoke-request.json', { nonce })
456
+ if (esId) {
457
+ const m = await buscarMiembro(arg.toUpperCase().includes('-') ? arg.toUpperCase() : arg.toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
458
+ writeReq('revoke-request.json', { sub: m.pub })
459
+ avisar(s.pid, 'SIGUSR2')
460
+ console.log('Quitado %s (todos sus certificados). Se autoborrará al reconectar. Verifica: dotrino-vault devices', m.id)
461
+ return
462
+ }
463
+ writeReq('revoke-request.json', { nonce: arg })
433
464
  avisar(s.pid, 'SIGUSR2')
434
- console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', nonce)
465
+ console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', arg)
435
466
  }
436
467
 
437
468
  /**
@@ -671,7 +702,7 @@ function help () {
671
702
  members el acta del perfil: quién es tuyo y qué puede hacer
672
703
  label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
673
704
  caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
674
- revoke <nonce> revoca un dispositivo (le ordena autoborrarse)
705
+ revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
675
706
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
676
707
  logs últimos logs del servicio
677
708
  version muestra la versión instalada
package/src/daemon.js CHANGED
@@ -222,10 +222,14 @@ export async function runDaemon () {
222
222
  console.log('[vault] permissions updated: %s', capsReq.caps.join(', ') || '(ninguno)')
223
223
  } catch (e) { console.error('[vault] could not change permissions:', e.message) }
224
224
  }
225
+ // Quitar un dispositivo: se pide por `sub` (la llave del aparato). `nonce` sigue
226
+ // aceptado para una consola vieja, pero retira UN certificado, no el aparato.
225
227
  const req = readJsonSafe(revokeReqFile)
226
- if (req?.nonce) {
227
- try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault] revoked nonce=%s', req.nonce) }
228
- catch (e) { console.error('[vault] revocation failed:', e.message) }
228
+ if (req?.sub || req?.nonce) {
229
+ try {
230
+ await targetOf(req)?.revokeDevice(req.sub ? { sub: req.sub } : { nonce: req.nonce })
231
+ console.log(req.sub ? '[vault] device removed' : '[vault] revoked nonce=%s', req.nonce || '')
232
+ } catch (e) { console.error('[vault] revocation failed:', e.message) }
229
233
  rm(revokeReqFile)
230
234
  }
231
235
  // Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
package/src/tui/app.js CHANGED
@@ -70,6 +70,24 @@ const shortScope = (scope) => {
70
70
  return arr.map((s) => String(s).replace(/^vault:/, '')).join(',') || '—'
71
71
  }
72
72
 
73
+ /**
74
+ * Un aparato = una llave (`sub`), aunque tenga varios certificados vigentes. Se queda con
75
+ * el de vencimiento más lejano (el último emitido) y suma los alcances de todos, para que
76
+ * la fila no prometa menos de lo que el aparato puede hacer de verdad.
77
+ */
78
+ function groupByDevice (issued) {
79
+ const by = new Map()
80
+ for (const d of issued) {
81
+ const key = d.sub || d.deviceId || d.nonce
82
+ const prev = by.get(key)
83
+ if (!prev) { by.set(key, { ...d, certCount: 1, scope: [...(Array.isArray(d.scope) ? d.scope : [d.scope])] }); continue }
84
+ prev.certCount++
85
+ for (const s of (Array.isArray(d.scope) ? d.scope : [d.scope])) if (!prev.scope.includes(s)) prev.scope.push(s)
86
+ if ((d.exp || 0) > (prev.exp || 0)) { prev.exp = d.exp; prev.nonce = d.nonce; prev.label = d.label || prev.label }
87
+ }
88
+ return [...by.values()]
89
+ }
90
+
73
91
  function activeProfile (st) {
74
92
  const list = st.profiles?.profiles || []
75
93
  return list.find((p) => p.current) || list[0] || null
@@ -172,13 +190,17 @@ function deviceRows (st, t) {
172
190
  rows.push({ text: t.warn(i.pendingDevice(pend.deviceId)) + t.muted(i.pendingHint), sel: false })
173
191
  rows.push({ text: '', sel: false })
174
192
  }
175
- const issued = st.devices?.issued || []
193
+ // UNA FILA POR APARATO, no por certificado. Antes se pintaba `issued` tal cual y un
194
+ // aparato con dos certs (el viejo + el de la renovación) salía dos veces: parecían dos
195
+ // máquinas. Se agrupa por llave y se muestra el cert vigente más largo.
196
+ const issued = groupByDevice(st.devices?.issued || [])
176
197
  if (!issued.length) {
177
198
  rows.push({ text: t.muted(i.noDevices), sel: false })
178
199
  }
179
200
  for (const d of issued) {
180
201
  const label = d.label || t.muted(i.noLabel)
181
- const line = ` ${t.bold(d.deviceId)} ${label} ${t.muted('scope:' + shortScope(d.scope))} ${t.muted('exp:' + fmtExp(d.exp))} ${t.muted('nonce:' + (d.nonce ?? ''))}`
202
+ 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}`
182
204
  rows.push({ text: line, sel: true, meta: d })
183
205
  }
184
206
  const revoked = st.devices?.revoked || []
@@ -523,12 +545,13 @@ async function onKeyDevices (term, st, key) {
523
545
  if (!st.pending) { flash(st, i.noPendingToReject, 'warn'); return true }
524
546
  const r = await guard(term, st, i.rejecting, () => vc.rejectPending(st.pending.deviceId, activeId(st)))
525
547
  if (r.ok) { flash(st, i.deviceRejected); st.pending = null }
526
- } else if ((ch === 'v' || key.name === 'delete') && cur?.nonce != null) { // revocar el enrolado seleccionado
548
+ } else if ((ch === 'v' || key.name === 'delete') && (cur?.sub || cur?.nonce != null)) { // quitar el aparato seleccionado
527
549
  setConfirm(st, {
528
550
  text: i.revokeConfirm(cur.deviceId),
529
551
  onYes: async () => {
530
552
  st.confirm = null
531
- const r = await guard(term, st, i.revoking, () => vc.revokeDevice(cur.nonce, activeId(st)))
553
+ // Por `sub`: se le retiran TODOS los certificados, no solo el de esta fila.
554
+ const r = await guard(term, st, i.revoking, () => vc.revokeDevice({ sub: cur.sub, nonce: cur.nonce }, activeId(st)))
532
555
  if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); st.devices = r.v; st.sel.devices = 0 }
533
556
  },
534
557
  onNo: () => { st.confirm = null }
package/src/vault.js CHANGED
@@ -217,10 +217,40 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
217
217
 
218
218
  // Lista (solo lectura) de dispositivos enrolados, para un panel en el navegador.
219
219
  // Cualquier cert válido tuyo puede verla; REVOCAR sigue siendo solo desde el PC.
220
+ /**
221
+ * Un aparato REVOCADO que vuelve a aparecer: se le reemite el `vault.revoked` FIRMADO
222
+ * para que se entere y se auto-borre.
223
+ *
224
+ * Un «unauthorized: revoked» suelto no basta y no debe bastar: no va firmado, así que
225
+ * el dispositivo tiene prohibido borrar nada con él (si no, cualquiera destruiría datos
226
+ * ajenos con un mensaje). Lo único que puede borrar es un aviso firmado por la maestra
227
+ * — y hasta ahora el daemon no lo reemitía nunca. Si el aparato estaba apagado cuando lo
228
+ * quitaste, no se enteraba jamás: seguía enseñando el perfil como si nada.
229
+ */
230
+ async function avisarSiRevocado (pubkey) {
231
+ if (typeof pubkey !== 'string') return
232
+ 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) })
243
+ }
244
+ } catch (e) { log('[vault] could not re-emit the revocation:', e.message) }
245
+ }
246
+
220
247
  async function handleDevices (from, p) {
221
248
  if (!isFresh(p.data)) return staleReply(from)
222
249
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
223
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
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
+ }
224
254
  const { issued, revoked } = await identity.listDelegations()
225
255
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
226
256
  // política (quién manda, quién puede qué) sin un canal aparte.
@@ -534,9 +564,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
534
564
  await notifyMembers('label', { deviceId: device, label: r?.label ?? label })
535
565
  return r
536
566
  },
537
- revokeDevice: async (nonce) => {
538
- const r = await desk.revoke(nonce)
539
- await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
567
+ // QUITAR EL DISPOSITIVO. Se identifica por su llave (`sub`), no por un `nonce`: un
568
+ // aparato puede tener varios certificados y retirar uno no lo echaba de la bóveda.
569
+ // Se sigue aceptando `{ nonce }` para no romper una consola vieja en vuelo.
570
+ revokeDevice: async (target) => {
571
+ const sub = typeof target === 'object' && target ? target.sub : null
572
+ if (!sub) {
573
+ const nonce = typeof target === 'string' ? target : target?.nonce
574
+ const r = await desk.revoke(nonce)
575
+ await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
576
+ return r
577
+ }
578
+ const r = await desk.revokeDevice(sub)
579
+ audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
580
+ await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
540
581
  return r
541
582
  },
542
583
  close () {
@@ -196,10 +196,36 @@ export async function listDevices (profile) {
196
196
  const { devices } = await snapshot(profile)
197
197
  if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
198
198
  const issued = devices.issued || devices.active || devices.delegations || []
199
+ const revoked = devices.revoked || []
199
200
  const withIds = await Promise.all(issued.map(async (d) => ({
200
201
  ...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
201
202
  })))
202
- return { issued: withIds, revoked: devices.revoked || [], profile: devices.profile || null }
203
+ return { issued: agruparPorAparato(withIds, revoked), revoked, profile: devices.profile || null }
204
+ }
205
+
206
+ /**
207
+ * UN APARATO, UNA FILA — y sin los certificados retirados.
208
+ *
209
+ * El daemon lleva la cuenta por CERTIFICADO, que es lo correcto para él: revocar es
210
+ * revocar un papel. Pero al dueño le sobra ese detalle — renovar emite uno nuevo cada 30
211
+ * días, así que un aparato de un año saldría doce veces, y los revocados seguían contando
212
+ * como «enrolados». Se agrupa por llave, se queda el más nuevo y se guardan TODOS sus
213
+ * nonces, que es lo que hace falta para retirarlo entero.
214
+ */
215
+ export function agruparPorAparato (lista, revoked = []) {
216
+ const fuera = new Set(revoked.map((r) => r?.nonce || r))
217
+ const porLlave = new Map()
218
+ for (const d of lista) {
219
+ if (d.revokedAt || fuera.has(d.nonce)) continue
220
+ const clave = d.sub || d.nonce
221
+ const y = porLlave.get(clave)
222
+ if (!y) porLlave.set(clave, { ...d, nonces: [d.nonce] })
223
+ else {
224
+ y.nonces.push(d.nonce)
225
+ if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, nonces: y.nonces })
226
+ }
227
+ }
228
+ return [...porLlave.values()]
203
229
  }
204
230
 
205
231
  /**
@@ -245,10 +271,15 @@ export async function setDeviceCaps (pub, caps, profile) {
245
271
  return listDevices(profile)
246
272
  }
247
273
 
248
- /** Revoca un dispositivo por su `nonce` (le ordena autoborrarse) y revuelca. */
249
- export async function revokeDevice (nonce, profile) {
274
+ /**
275
+ * Quita un dispositivo por su llave `sub` (le ordena autoborrarse) y revuelca.
276
+ * Se acepta un `nonce` suelto por compatibilidad, pero eso retira UN certificado:
277
+ * un aparato puede tener varios y seguiría entrando con el otro.
278
+ */
279
+ export async function revokeDevice (target, profile) {
250
280
  requireAlive()
251
- writeReq(F.revokeReq, { nonce }, profile)
281
+ const req = typeof target === 'string' ? { nonce: target } : { sub: target?.sub, nonce: target?.nonce }
282
+ writeReq(F.revokeReq, req, profile)
252
283
  signalOrCleanup('SIGUSR2', [F.revokeReq])
253
284
  await sleep(300)
254
285
  return listDevices(profile)