@dotrino/vaultd 0.141.0 → 0.142.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/README.md CHANGED
@@ -237,6 +237,7 @@ dotrino-vault devices # lista dispositivos enrolados / revocados
237
237
  dotrino-vault members # el acta del perfil: qué llaves son tuyas y qué puede cada una
238
238
  dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma -guarda +lee +administra +aprueba …)
239
239
  dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
240
+ dotrino-vault block <ID> # lo deja fuera sin quitarlo del acta; unblock <ID> lo deshace (solo aquí o por el admin)
240
241
  dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
241
242
  dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
242
243
  dotrino-vault pair --scope <lista> # los PERMISOS con los que entra (cualquiera de caps: firma,lee,guarda,administra,aprueba…
package/lib/src/admin.js CHANGED
@@ -47,6 +47,9 @@ export const ADMIN_OPS = Object.freeze([
47
47
  // `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
48
48
  // a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
49
49
  'revoke', 'audit',
50
+ // Los BLOQUEADOS por un aprobador (`src/blocked.js`): verlos y desbloquearlos. Bloquear a
51
+ // distancia también, por si el administrador llega antes que el teléfono. No toca el acta.
52
+ 'blocked', 'block', 'unblock',
50
53
  // Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
51
54
  // ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
52
55
  // no debe poder dejar sin configuración a los servicios.
@@ -96,6 +99,8 @@ export const ADMIN_OPS = Object.freeze([
96
99
  */
97
100
  export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
98
101
  'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
102
+ // Bloquear no sella nada: la lista es de la bóveda, no del acta.
103
+ 'blocked', 'block', 'unblock',
99
104
  // Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
100
105
  // echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
101
106
  // y frescura como todas, y además la contraseña dentro del sobre.
@@ -147,6 +152,8 @@ export function createAdminDesk ({
147
152
  // este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
148
153
  // operación existe y contesta que no, en vez de romper con un TypeError.
149
154
  unlockDesk = null,
155
+ // La lista de bloqueados, inyectada como `vars`: `{ list(), set(pub, on, { by }) }`.
156
+ blocked = null,
150
157
  now = () => Date.now()
151
158
  }) {
152
159
  const ops = new Set(ADMIN_OPS)
@@ -333,6 +340,14 @@ export function createAdminDesk ({
333
340
  // usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
334
341
  // mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
335
342
  // significa «renueva»). Ese era el dispositivo fantasma.
343
+ if (data.op === 'blocked' || data.op === 'block' || data.op === 'unblock') {
344
+ if (!blocked) return { ok: false, error: 'admin: this vault cannot block devices' }
345
+ if (data.op === 'blocked') return { ok: true, result: { items: blocked.list() } }
346
+ if (typeof data.sub !== 'string' || !data.sub) return { ok: false, error: `admin: ${data.op} needs the device key (sub)` }
347
+ const r = await blocked.set(data.sub, data.op === 'block', { by })
348
+ audit(`admin.${data.op}`, { by, device: await deviceIdOf(data.sub).catch(() => null) })
349
+ return { ok: true, result: { ok: true, was: r } }
350
+ }
336
351
  if (data.op === 'revoke') {
337
352
  if (data.sub) {
338
353
  const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
@@ -103,6 +103,13 @@ export const MSG = Object.freeze({
103
103
  // una forma de echar a alguien de su cuenta.
104
104
  LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
105
105
  LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
106
+ // INCIDENTE: un agente de la cuenta (la terminal) avisa de que OTRO aparato de la cuenta
107
+ // se equivocó de clave tres veces seguidas. La bóveda lo apunta como un pedido más
108
+ // (`kind: 'incident'`), timbra a quien aprueba, y el aprobador bloquea o ignora. Bloquear
109
+ // no toca el acta: es una lista de la bóveda (`blocked.json`) que viaja a los agentes con
110
+ // `vault.devices.result` (`blocked`) y se deshace con `dotrino-vault unblock <ID>`.
111
+ INCIDENT: 'vault.incident', // agente → vault: { data:{op:'incident',kind,about,tries,publickey,ts}, signature, cert }
112
+ INCIDENT_RESULT: 'vault.incident.result', // vault → agente: { id, approvers, blocked }
106
113
  ERROR: 'vault.error' // vault → dispositivo: { error }
107
114
  })
108
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.141.0",
3
+ "version": "0.142.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": {
@@ -50,7 +50,7 @@
50
50
  "!lib/src/types.d.ts"
51
51
  ],
52
52
  "devDependencies": {
53
- "@dotrino/remote-agent": "0.10.0",
53
+ "@dotrino/remote-agent": "0.13.0",
54
54
  "@types/node": "^22.0.0",
55
55
  "typescript": "5.9.3"
56
56
  },
package/src/blocked.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * LOS APARATOS BLOQUEADOS: la lista que un aprobador decide y solo la bóveda deshace.
3
+ *
4
+ * Un agente (la terminal, por ahora) reporta un INCIDENTE cuando un aparato de la cuenta se
5
+ * equivoca de clave tres veces seguidas. El aprobador lo ve en su teléfono y elige: bloquear
6
+ * o ignorar. Bloquear NO es revocar: el aparato sigue en el acta —no hace falta la maestra, y
7
+ * la bóveda vive cerrada—, pero la bóveda deja de atenderle y los agentes, que reciben esta
8
+ * lista con el acta, le cierran la puerta. Se deshace aquí (`dotrino-vault unblock <ID>`) o
9
+ * desde la consola de administración; un aprobador solo bloquea.
10
+ *
11
+ * Va en el disco cifrado en reposo como las contraseñas: es parte del mapa de la cuenta.
12
+ * Se carga UNA vez: un archivo que existe y no se puede leer NO es «nadie bloqueado» — se
13
+ * lanza, y la bóveda no arranca con una lista que no puede saber.
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+
18
+ export const BLOCKED_FILE = 'blocked.json'
19
+
20
+ const fail = (code, message) => Object.assign(new Error(message), { code })
21
+
22
+ /**
23
+ * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
24
+ */
25
+ export function openBlocked ({ dir, atRest, now = Date.now }) {
26
+ const file = path.join(dir, BLOCKED_FILE)
27
+ /** pub → { deviceId, label, since, by, reason } */
28
+ let items = load()
29
+
30
+ function load () {
31
+ let raw
32
+ try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
33
+ if (e.code === 'ENOENT') return new Map()
34
+ throw fail('blocked-unreadable', `cannot read ${BLOCKED_FILE}: ${e.message}`)
35
+ }
36
+ let d
37
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('blocked-unreadable', `${BLOCKED_FILE} cannot be opened: ${e.message}`) }
38
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('blocked-unreadable', `${BLOCKED_FILE} has an unknown shape`)
39
+ return new Map(Object.entries(d.items))
40
+ }
41
+
42
+ function save () {
43
+ const tmp = file + '.tmp'
44
+ fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
45
+ fs.renameSync(tmp, file)
46
+ }
47
+
48
+ const publicOf = (pub, b) => ({ pub, deviceId: b.deviceId || null, label: b.label || '', since: b.since, by: b.by || null, reason: b.reason || null })
49
+
50
+ return {
51
+ has: (pub) => items.has(pub),
52
+ pubs: () => [...items.keys()],
53
+ list: () => [...items].map(([pub, b]) => publicOf(pub, b)),
54
+ /** Bloquea. Devuelve la entrada; si ya estaba, la que había (no se pisa el «desde cuándo»). */
55
+ block ({ pub, deviceId = null, label = '', by = null, reason = null }) {
56
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'block: a device key is required')
57
+ if (items.has(pub)) return publicOf(pub, items.get(pub))
58
+ const b = { deviceId, label, since: now(), by, reason }
59
+ items.set(pub, b)
60
+ save()
61
+ return publicOf(pub, b)
62
+ },
63
+ /** Desbloquea. `true` si estaba. */
64
+ unblock (pub) {
65
+ if (!items.has(pub)) return false
66
+ items.delete(pub)
67
+ save()
68
+ return true
69
+ }
70
+ }
71
+ }
package/src/ctl.js CHANGED
@@ -140,7 +140,7 @@ function cmdStatus () {
140
140
  // corriendo es más viejo que el CLI, avisar (nos mordió 3 veces).
141
141
  if (s.version && s.version !== VERSION) {
142
142
  console.log(' ⚠ el servicio corre la versión %s (binario instalado: %s).', s.version, VERSION)
143
- console.log(' Reinicia para actualizarlo: %s', RESTART_HINT)
143
+ console.log(' Si acabas de actualizar, se pone al día solo en uno o dos minutos. Si no: %s', RESTART_HINT)
144
144
  }
145
145
  console.log(' fingerprint : %s', s.fingerprint)
146
146
  console.log(' proxy : %s', s.proxy)
@@ -598,7 +598,11 @@ async function cmdMembers () {
598
598
  console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, profileId, record.seq)
599
599
  for (const m of record.members) {
600
600
  const who = m.label || m.id
601
+ const bloqueo = (record.blocked || []).find((b) => b.pub === m.pub)
601
602
  const marks = [
603
+ // BLOQUEADO va primero y resaltado: es lo que hay que ver antes que ningún permiso,
604
+ // porque mientras dure ningún permiso cuenta.
605
+ bloqueo ? `${B}BLOQUEADO${Z} desde el ${fechaCorta(bloqueo.since)} (dotrino-vault unblock ${m.id})` : null,
602
606
  // Ya no hay UN master: la marca dice quién puede sellar, y pueden ser varios. El
603
607
  // permiso también sale abajo en la lista, pero aquí se ve de un vistazo — que es
604
608
  // justo lo que se busca al mirar quién es quién.
@@ -667,6 +671,33 @@ async function cmdLabel (args = []) {
667
671
  console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, name.slice(0, 60))
668
672
  }
669
673
 
674
+ /**
675
+ * BLOQUEAR / DESBLOQUEAR un aparato de la cuenta. Lo normal es que bloquee el aprobador desde
676
+ * el teléfono, al recibir el incidente de un agente (la terminal: tres claves mal seguidas);
677
+ * desbloquear SOLO se hace aquí o desde la consola de administración (dueño, 2026-10-07).
678
+ * No toca el acta: el aparato sigue siendo miembro, pero la bóveda y los agentes no le
679
+ * atienden hasta que se le quite el bloqueo.
680
+ */
681
+ async function cmdBlock (on, args = []) {
682
+ const [id] = args
683
+ const verb = on ? 'block' : 'unblock'
684
+ if (!id) { console.error(`uso: dotrino-vault ${verb} <ID>`); process.exit(2) }
685
+ const s = requireDaemon()
686
+ const m = await findMember(id)
687
+ const resFile = path.join(dataDir(), 'block.json')
688
+ try { fs.rmSync(resFile, { force: true }) } catch (_) {}
689
+ const reqId = Math.random().toString(36).slice(2)
690
+ writeReq('block-request.json', { id: reqId, pub: m.pub, on })
691
+ sendSignal(s.pid, 'SIGUSR2')
692
+ let r = null
693
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = ipcRead(resFile, null); if (a?.at && a.req === reqId) { r = a; break } }
694
+ if (!r) { console.error('El daemon no respondió.'); process.exit(1) }
695
+ if (!r.ok) { console.error('No se pudo %s: %s', on ? 'bloquear' : 'desbloquear', r.error); process.exit(1) }
696
+ if (on) console.log('Bloqueado %s. La bóveda y tus agentes no le atienden hasta: dotrino-vault unblock %s', m.id, m.id)
697
+ else if (r.was) console.log('Desbloqueado %s. Compruébalo con: dotrino-vault members', m.id)
698
+ else console.log('%s no estaba bloqueado.', m.id)
699
+ }
700
+
670
701
  /** Busca un miembro del acta por su identificador (AB12-CD34) o se rinde con un mensaje claro. */
671
702
  async function findMember (id) {
672
703
  const s = requireDaemon()
@@ -1732,6 +1763,30 @@ async function cmdLogins (rest) {
1732
1763
  * Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
1733
1764
  * va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
1734
1765
  */
1766
+ /**
1767
+ * ESPERA A QUE EL SERVICIO CORRA LA VERSIÓN INSTALADA, y lo dice. El servicio se reinicia
1768
+ * solo cuando ve el binario nuevo (`selfupdate.js`), pero espera a verlo quieto dos minutos
1769
+ * seguidos: decir «se reinicia solo, compruébalo con status» y que `status` contestara un
1770
+ * minuto después «reinicia para actualizarlo» era mandar a hacer a mano lo que ya iba a pasar.
1771
+ */
1772
+ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1773
+ const s0 = ipcRead(stateFile, null)
1774
+ if (!s0 || !alive(s0.pid)) {
1775
+ console.log('El servicio no está corriendo: arráncalo y ya será la %s. %s', version, START_HINT)
1776
+ return false
1777
+ }
1778
+ process.stdout.write('El servicio la arranca solo en uno o dos minutos. Esperando… ')
1779
+ const t0 = Date.now()
1780
+ while (Date.now() - t0 < timeoutMs) {
1781
+ const s = ipcRead(stateFile, null)
1782
+ if (s?.version === version && alive(s.pid)) { console.log('✓ ya corre la %s', version); return true }
1783
+ await sleep(3000)
1784
+ }
1785
+ console.log('')
1786
+ console.error('El servicio sigue sin correr la %s después de %d min. Reinícialo: %s', version, Math.round(timeoutMs / 60000), RESTART_HINT)
1787
+ return false
1788
+ }
1789
+
1735
1790
  async function cmdUpdate (args = []) {
1736
1791
  const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1737
1792
  const soloMirar = args.includes('--check')
@@ -1763,7 +1818,8 @@ async function cmdUpdate (args = []) {
1763
1818
  process.exit(1)
1764
1819
  }
1765
1820
  console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1766
- console.log('Instalada la %s. El servicio se reinicia solo; compruébalo con: dotrino-vault status', res.version)
1821
+ console.log('Instalada la %s.', res.version)
1822
+ if (!args.includes('--no-wait')) await waitForService(res.version)
1767
1823
  return
1768
1824
  }
1769
1825
 
@@ -2110,6 +2166,8 @@ function help () {
2110
2166
 
2111
2167
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
2112
2168
  status | info estado del servicio + fingerprint + el id de cada perfil
2169
+ update [--check] trae la versión nueva (verificando la firma) y espera a que el
2170
+ servicio la arranque · --check solo mira · --no-wait no espera
2113
2171
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
2114
2172
  pair --kms <config.json>
2115
2173
  el sitio que se cree (--adopt o --new-account) NACE con su clave
@@ -2182,6 +2240,10 @@ function help () {
2182
2240
  +sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
2183
2241
  que perder una máquina no se la lleve. No es un traspaso
2184
2242
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
2243
+ block <ID> bloquea un aparato sin quitarlo: la bóveda y tus agentes dejan de
2244
+ atenderle. Lo normal es bloquear desde el teléfono al recibir el
2245
+ incidente (la terminal: tres claves mal seguidas)
2246
+ unblock <ID> le quita el bloqueo (solo desde aquí o desde la consola de admin)
2185
2247
  logins los aparatos que se abren con usuario y contraseña (para un equipo
2186
2248
  prestado, donde no puedes emparejar nada)
2187
2249
  logins add <usuario> [nombre del equipo] [±permiso …]
@@ -2320,6 +2382,10 @@ async function cmdReplica (args) {
2320
2382
 
2321
2383
  export async function runCtl (argv) {
2322
2384
  const [cmd, ...rest] = takeProfileFlag(argv)
2385
+ // `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
2386
+ // así que se ejecutaban igual: `pair --help` lanzaba una invitación real y `update --help`
2387
+ // instalaba la versión nueva (2026-10-05). Mirar cómo se usa algo no puede cambiar nada.
2388
+ if (rest.includes('--help') || rest.includes('-h')) return help()
2323
2389
  switch (cmd) {
2324
2390
  case 'tui': return cmdTui()
2325
2391
  case 'profile': return cmdProfile(rest)
@@ -2340,6 +2406,8 @@ export async function runCtl (argv) {
2340
2406
  case 'label': return cmdLabel(rest)
2341
2407
  case 'caps': return cmdCaps(rest)
2342
2408
  case 'revoke': return cmdRevoke(rest[0])
2409
+ case 'block': return cmdBlock(true, rest)
2410
+ case 'unblock': return cmdBlock(false, rest)
2343
2411
  case 'logins': return cmdLogins(rest)
2344
2412
  case 'update': return cmdUpdate(rest)
2345
2413
  case 'secret': return cmdSecret(rest)
package/src/daemon.js CHANGED
@@ -638,6 +638,25 @@ export async function runDaemon () {
638
638
  console.log('[vault] device renamed: %s', labelReq.label || '(no name)')
639
639
  } catch (e) { console.error('[vault] could not rename the device:', e.message) }
640
640
  }
641
+ // BLOQUEAR / DESBLOQUEAR un aparato (`dotrino-vault block|unblock <ID>`). No toca el
642
+ // acta, así que funciona con el perfil cerrado; se contesta en `block.json`.
643
+ const blockReq = readJsonSafe(path.join(dir, 'block-request.json'))
644
+ if (blockReq?.pub && typeof blockReq.on === 'boolean') {
645
+ rm(path.join(dir, 'block-request.json'))
646
+ const answer = (extra) => ipcWrite(path.join(dir, 'block.json'), { v: 1, at: Date.now(), req: blockReq.id || null, ...extra })
647
+ try {
648
+ // Con el perfil CERRADO también: bloquear no sella nada, y un aparato que está
649
+ // probando claves no espera a que el dueño abra la bóveda.
650
+ const vault = resolveTarget(blockReq)?.vault
651
+ if (!vault) throw Object.assign(new Error('no vault for that profile'), { code: 'NO_PROFILE' })
652
+ const r = await vault.setBlocked(blockReq.pub, blockReq.on, { by: 'vault' })
653
+ console.log('[vault] device %s', blockReq.on ? 'blocked' : (r ? 'unblocked' : 'was not blocked'))
654
+ answer({ ok: true, on: blockReq.on, was: r })
655
+ } catch (e) {
656
+ console.error('[vault] could not %s the device: %s', blockReq.on ? 'block' : 'unblock', e.message)
657
+ answer({ ok: false, error: e.message, code: e.code || 'BLOCK_FAILED' })
658
+ }
659
+ }
641
660
  const capsReq = readJsonSafe(path.join(dir, 'caps-request.json'))
642
661
  // VARIOS APARATOS, UNA ACTA (el borrador de la TUI). Se contesta con el `id` de la
643
662
  // petición, para que quien espera sepa si se firmó de verdad.
@@ -894,7 +913,7 @@ export async function runDaemon () {
894
913
  lastSecretHistory = null
895
914
  ipcWrite(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
896
915
  // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
897
- try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
916
+ try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()), blocked: t.vault.listBlocked?.() || [] }) } catch (_) {}
898
917
  }
899
918
 
900
919
  // PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
package/src/vault.js CHANGED
@@ -29,6 +29,7 @@ import { openStore } from './store.js'
29
29
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
30
30
  import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
31
31
  import { openSubacta } from './subacta.js'
32
+ import { openBlocked } from './blocked.js'
32
33
  import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
33
34
  import { VERSION } from './version.js'
34
35
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
@@ -259,6 +260,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
259
260
  // además un emparejamiento paralelo al del ecosistema.
260
261
  const passwordsFile = path.join(dir, 'passwords.json')
261
262
  const passwordsAtRest = atRestFor(dir)
263
+ // Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
264
+ const blocked = openBlocked({ dir, atRest: passwordsAtRest })
262
265
  const readPasswordsFile = () => {
263
266
  try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
264
267
  }
@@ -603,6 +606,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
603
606
  const verifyChain = async (args) => {
604
607
  const chk = await verifyChainRaw(args)
605
608
  if (chk.ok && args?.data?.v) apuntarVersion(chk.device, args.data.v)
609
+ // UN BLOQUEADO NO PASA POR NINGÚN MOSTRADOR. Sigue en el acta —bloquear no la toca,
610
+ // porque la bóveda vive cerrada y eso lo decide un aprobador desde el teléfono—, así que
611
+ // el papel verifica; la puerta se cierra aquí, que es por donde entra todo, y con su
612
+ // propio `reason` para que el otro lado sepa qué le pasa y no lo confunda con un papel
613
+ // viejo (que se arregla renovando) ni con una revocación (que se arregla emparejando).
614
+ if (chk.ok && blocked.has(chk.device)) return { ok: false, reason: 'blocked', device: chk.device }
606
615
  return chk
607
616
  }
608
617
 
@@ -1281,7 +1290,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1281
1290
  return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
1282
1291
  }
1283
1292
  audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
1284
- return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, acta: record })
1293
+ return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, blocked: blocked.pubs(), acta: record })
1285
1294
  }
1286
1295
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
1287
1296
  // política (quién manda, quién puede qué) sin un canal aparte.
@@ -1313,7 +1322,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1313
1322
  // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1314
1323
  // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
1315
1324
  // Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
1316
- reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
1325
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, blocked: blocked.pubs(), acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
1317
1326
  }
1318
1327
 
1319
1328
  /**
@@ -1580,7 +1589,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1580
1589
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
1581
1590
  if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1582
1591
  if (p.data?.op === 'digest') return handleDigest(from, p)
1583
- if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
1592
+ if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
1584
1593
  const ns = p.data?.ns
1585
1594
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
1586
1595
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -1759,6 +1768,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1759
1768
  // renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
1760
1769
  // el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
1761
1770
  if (op === 'grants') return answer({ op: 'grants', items: await conContextoSellado(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
1771
+ // LOS BLOQUEADOS SE VEN desde donde se bloquean. Desbloquear no: eso es de la bóveda
1772
+ // (`dotrino-vault unblock <ID>`) o del administrador, que es lo que pidió el dueño.
1773
+ if (op === 'blocked') return answer({ op: 'blocked', items: blocked.list() })
1762
1774
  if (op === 'grant-revoke') {
1763
1775
  const gid = typeof p.data?.id === 'string' ? p.data.id : ''
1764
1776
  const ok = gid ? grants.revoke(gid) : false
@@ -1771,6 +1783,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1771
1783
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
1772
1784
  const pend = approvals.take(id)
1773
1785
  if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
1786
+ // UN INCIDENTE no entrega nada: se contesta BLOQUEAR (`block`) o IGNORAR (`deny`).
1787
+ // `approve` no significa nada aquí y se rechaza en voz alta: el pedido vuelve a la mesa
1788
+ // para que lo conteste con la palabra correcta.
1789
+ if (pend.kind === 'incident') {
1790
+ if (op !== 'block' && op !== 'deny') {
1791
+ approvals.request({ ...pend, ttlMs: Math.max(1000, pend.exp - Date.now()) })
1792
+ return reply(from, { type: MSG.ERROR, error: 'approval: an incident is answered with block or deny' })
1793
+ }
1794
+ if (op === 'deny') {
1795
+ audit('incident.ignored', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by })
1796
+ log(`[vault] incident ${id} about ${pend.deviceId} ignored by ${by}`)
1797
+ return answer({ op: 'deny.result', id, ok: true })
1798
+ }
1799
+ const b = blocked.block({ pub: pend.device, deviceId: pend.deviceId, label: pend.label, by, reason: pend.ctx?.kind || 'incident' })
1800
+ // Lo que ya tenía concedido se corta en el acto: bloquear y dejarle pasar la hora que
1801
+ // le quedaba sería un bloqueo de mentira.
1802
+ const cut = grants.revokeAll({ device: pend.device })
1803
+ audit('blocked', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by, grants: cut })
1804
+ log(`[vault] BLOCKED ${pend.deviceId}${pend.label ? ` (${pend.label})` : ''} by ${by} after incident ${id} · unblock with: dotrino-vault unblock ${pend.deviceId}`)
1805
+ // Se avisa a todos: los agentes (la terminal) traen la lista con el acta al recibirlo
1806
+ // y le cierran la sesión que tenga abierta. Sin esto el bloqueo esperaba al tic de 5 min.
1807
+ await notifyMembers('blocked', { deviceId: pend.deviceId, by })
1808
+ return answer({ op: 'block.result', id, ok: true, deviceId: pend.deviceId, since: b.since })
1809
+ }
1810
+ if (op === 'block') return reply(from, { type: MSG.ERROR, error: 'approval: only an incident can be blocked' })
1774
1811
  // PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
1775
1812
  // resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
1776
1813
  // que sellar, solo una promesa esperando un sí o un no.
@@ -1903,6 +1940,57 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1903
1940
  } catch (e) { log('[vault] could not notify approvers:', e.message) }
1904
1941
  }
1905
1942
 
1943
+ /**
1944
+ * UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
1945
+ * clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
1946
+ * nada: se apunta como un pedido más, de tipo `incident`, y se timbra a quien aprueba,
1947
+ * que es quien bloquea o ignora desde el teléfono. Sin nadie que apruebe no hay a quién
1948
+ * preguntar: se anota en la bitácora y se dice, y el agente se queda con su freno local.
1949
+ *
1950
+ * Quien reporta tiene que ser un miembro con `sign` (es lo que lo hace agente), y el
1951
+ * señalado tiene que estar en el acta: un incidente sobre un desconocido no es un incidente.
1952
+ * Y si el señalado ya está bloqueado no se vuelve a timbrar: se contesta que lo está.
1953
+ */
1954
+ async function handleIncident (from, p) {
1955
+ if (!isFresh(p.data)) return staleReply(from)
1956
+ const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
1957
+ if (!chk.ok) return denyChain(from, chk, p, 'incident')
1958
+ const record = await refreshActa()
1959
+ if (!record || !Acta.memberCan(record, chk.device, 'sign')) {
1960
+ audit('rejected', { what: 'incident', reason: record ? 'acta' : 'sin-acta' })
1961
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member cannot report incidents' })
1962
+ }
1963
+ const d = p.data || {}
1964
+ const kind = typeof d.kind === 'string' ? d.kind.slice(0, 32) : ''
1965
+ const about = typeof d.about === 'string' ? d.about : ''
1966
+ const tries = Number.isInteger(d.tries) ? d.tries : null
1967
+ if (d.op !== 'incident' || !kind || !about) return reply(from, { type: MSG.ERROR, error: 'incident: op, kind and about are required' })
1968
+ const target = (record.members || []).find((m) => m.pub === about)
1969
+ if (!target) return reply(from, { type: MSG.ERROR, code: 'unknown-device', error: 'incident: that device is not in the record' })
1970
+ const by = await deviceIdOf(chk.device).catch(() => null)
1971
+ const byLabel = (record.members || []).find((m) => m.pub === chk.device)?.label || ''
1972
+ const deviceId = await deviceIdOf(about).catch(() => null)
1973
+ audit('incident', { device: deviceId, by, kind, tries })
1974
+ if (blocked.has(about)) {
1975
+ log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: already blocked`)
1976
+ return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: true })
1977
+ }
1978
+ const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
1979
+ if (!approvers) {
1980
+ log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: nobody can approve, so nobody is asked (grant it with: dotrino-vault caps <ID> +aprueba)`)
1981
+ return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: false })
1982
+ }
1983
+ // `ns` es el que reporta: la pantalla dice «X falló la clave de la terminal de Y». El
1984
+ // contexto va sellado al aprobador como el de cualquier pedido.
1985
+ const pend = approvals.request({
1986
+ ns: by || 'incident', kind: 'incident', device: about, deviceId, label: target.label || '', ek: '',
1987
+ ctx: { kind, tries, reporter: by, reporterLabel: byLabel }, from, ttlMs: UPDATE_TTL_MS
1988
+ })
1989
+ log(`[vault] incident (${kind}) about ${deviceId}${target.label ? ` (${target.label})` : ''} reported by ${by}: asking ${approvers} approver(s) (${pend.id})`)
1990
+ await notifyApprovers(pend, record)
1991
+ reply(from, { type: MSG.INCIDENT_RESULT, id: pend.id, approvers, blocked: false })
1992
+ }
1993
+
1906
1994
  client.on('message', (from, payload) => onMessage(from, payload))
1907
1995
 
1908
1996
  // EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
@@ -1933,6 +2021,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1933
2021
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
1934
2022
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
1935
2023
  if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
2024
+ if (payload.type === MSG.INCIDENT) return await handleIncident(from, payload)
1936
2025
  if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
1937
2026
  if (payload.type === MSG.LOGIN_START) return await handleLoginStart(from, payload)
1938
2027
  if (payload.type === MSG.LOGIN_FINISH) return await handleLoginFinish(from, payload)
@@ -2611,10 +2700,37 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2611
2700
  return pend.id
2612
2701
  }
2613
2702
 
2703
+ /**
2704
+ * BLOQUEAR O DESBLOQUEAR A MANO, desde la bóveda (CLI/TUI) o desde la consola de
2705
+ * administración. El camino normal para bloquear es el aprobador (`handleApproval`,
2706
+ * `block`); desbloquear solo pasa por aquí, que es lo que pidió el dueño.
2707
+ */
2708
+ async function setBlocked (pub, on, { by = 'vault' } = {}) {
2709
+ const deviceId = await deviceIdOf(pub).catch(() => null)
2710
+ if (on) {
2711
+ const record = await refreshActa()
2712
+ const label = (record?.members || []).find((m) => m.pub === pub)?.label || ''
2713
+ const b = blocked.block({ pub, deviceId, label, by, reason: 'manual' })
2714
+ grants.revokeAll({ device: pub })
2715
+ audit('blocked', { device: deviceId, by, kind: 'manual' })
2716
+ log(`[vault] BLOCKED ${deviceId} by ${by}`)
2717
+ await notifyMembers('blocked', { deviceId, by })
2718
+ return b
2719
+ }
2720
+ const was = blocked.unblock(pub)
2721
+ if (was) {
2722
+ audit('unblocked', { device: deviceId, by })
2723
+ log(`[vault] unblocked ${deviceId} by ${by}`)
2724
+ await notifyMembers('unblocked', { deviceId, by })
2725
+ }
2726
+ return was
2727
+ }
2728
+
2614
2729
  const admin = createAdminDesk({
2615
2730
  desk,
2616
2731
  deviceIdOf,
2617
2732
  audit,
2733
+ blocked: { list: () => blocked.list(), set: setBlocked },
2618
2734
  // Abrir a distancia. Va inyectado porque `admin.js` es puro y esto necesita cripto y
2619
2735
  // disco; `null` si este perfil no lo soporta, y entonces la operación contesta que no.
2620
2736
  unlockDesk,
@@ -4058,6 +4174,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4058
4174
  // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
4059
4175
  rotationsDue,
4060
4176
  secretDebts,
4177
+ // LOS BLOQUEADOS, y quitarlos de la lista (`dotrino-vault block|unblock <ID>`).
4178
+ listBlocked: () => blocked.list(),
4179
+ setBlocked,
4061
4180
  /**
4062
4181
  * QUIÉN tiene envoltura de la generación vigente de un cajón (llaves de firma, más
4063
4182
  * `#recovery`). Es diagnóstico: saber a cuántos se les envolvió no ayuda a abrir nada,