@dotrino/vaultd 0.141.1 → 0.143.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.1",
3
+ "version": "0.143.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
@@ -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()
@@ -2209,6 +2240,10 @@ function help () {
2209
2240
  +sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
2210
2241
  que perder una máquina no se la lleve. No es un traspaso
2211
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)
2212
2247
  logins los aparatos que se abren con usuario y contraseña (para un equipo
2213
2248
  prestado, donde no puedes emparejar nada)
2214
2249
  logins add <usuario> [nombre del equipo] [±permiso …]
@@ -2371,6 +2406,8 @@ export async function runCtl (argv) {
2371
2406
  case 'label': return cmdLabel(rest)
2372
2407
  case 'caps': return cmdCaps(rest)
2373
2408
  case 'revoke': return cmdRevoke(rest[0])
2409
+ case 'block': return cmdBlock(true, rest)
2410
+ case 'unblock': return cmdBlock(false, rest)
2374
2411
  case 'logins': return cmdLogins(rest)
2375
2412
  case 'update': return cmdUpdate(rest)
2376
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/tui/app.js CHANGED
@@ -160,6 +160,9 @@ function mergeMembersAndCerts (members, issued) {
160
160
  })
161
161
  }
162
162
 
163
+ /** La entrada de bloqueo de un aparato (por su llave), o `null` si no está bloqueado. */
164
+ const blockedOf = (st, pub) => (pub && (st.blocked || []).find((b) => b.pub === pub)) || null
165
+
163
166
  function activeProfile (st) {
164
167
  const list = st.profiles?.profiles || []
165
168
  return list.find((p) => p.current) || list[0] || null
@@ -388,11 +391,15 @@ function deviceRows (st, t) {
388
391
  (debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
389
392
  // SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
390
393
  // en el color de aviso y en el sitio donde estaría su vencimiento.
391
- const status = d.noAccess
394
+ // BLOQUEADO: sigue en el acta pero la bóveda y los agentes no le atienden. Es un
395
+ // aviso (lo decidió un aprobador o el dueño), así que va en el color de aviso y
396
+ // delante de todo lo demás: es lo primero que hay que ver de esa fila.
397
+ const bloq = blockedOf(st, d.sub)
398
+ const status = (bloq ? t.warn(i.deviceBlocked) + ' ' : '') + (d.noAccess
392
399
  ? t.warn(i.deviceNoAccess)
393
400
  : d.isMaster
394
401
  ? t.muted(i.thisVault)
395
- : t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
402
+ : t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp)))
396
403
  const marca = conCambios.has(d.sub) ? t.bold('*') : ' '
397
404
  rows.push({ text: `${marca}${t.bold(d.deviceId)} ${label}${desde} ${status}${extra}`, sel: true, meta: d })
398
405
  }
@@ -909,7 +916,7 @@ async function refreshAll (term, st, api = vc) {
909
916
  // Cerrada: no se pide su contenido, y tampoco se enseña un error por mirarla desde
910
917
  // fuera. Lo que hubiera cargado se suelta, para no dejar en pantalla lo de antes.
911
918
  if (activeLocked(st)) {
912
- st.devices = null; st.secrets = null; st.members = []; st.me = undefined; st.capsDrafts = {}
919
+ st.devices = null; st.secrets = null; st.members = []; st.blocked = []; st.me = undefined; st.capsDrafts = {}
913
920
  return
914
921
  }
915
922
  const r = await guard(term, st, L(st).loading, () => api.snapshot(activeId(st)))
@@ -920,7 +927,7 @@ async function refreshAll (term, st, api = vc) {
920
927
  if (secrets) st.secrets = { ns: secrets.ns || {}, dev: Array.isArray(secrets.dev) ? secrets.dev : [] }
921
928
  // El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
922
929
  // `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
923
- if (record) st.members = record.members || []
930
+ if (record) { st.members = record.members || []; st.blocked = Array.isArray(record.blocked) ? record.blocked : [] }
924
931
  if (devices) {
925
932
  const issued = (devices.issued || devices.active || devices.delegations || [])
926
933
  st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await api.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
@@ -939,6 +946,7 @@ async function refreshAll (term, st, api = vc) {
939
946
  function applyDump (st, v) {
940
947
  st.devices = v
941
948
  if (Array.isArray(v?.members)) st.members = v.members
949
+ if (Array.isArray(v?.blocked)) st.blocked = v.blocked
942
950
  }
943
951
 
944
952
  async function refreshDevices (term, st) {
@@ -1257,6 +1265,20 @@ async function onKeyDevices (term, st, key) {
1257
1265
  },
1258
1266
  onNo: () => { st.confirm = null }
1259
1267
  })
1268
+ } else if (ch === 'b' && cur?.isMaster) {
1269
+ flash(st, i.cantBlockMaster, 'warn')
1270
+ } else if (ch === 'b' && cur?.sub) {
1271
+ // BLOQUEAR / DESBLOQUEAR (dueño, 2026-10-07). No toca el acta: el aparato sigue siendo
1272
+ // miembro, pero la bóveda y los agentes no le atienden hasta que se le quite. Lo normal
1273
+ // es que bloquee el aprobador desde el teléfono al recibir un incidente; desbloquear
1274
+ // SOLO se hace aquí o desde la consola de administración. `b` = bloquear en Bóvedas
1275
+ // también (k bloquea el perfil; esto es otro candado y otra tecla, para no confundir).
1276
+ const on = !blockedOf(st, cur.sub)
1277
+ const r = await guard(term, st, on ? i.blocking : i.unblocking, () => vc.setDeviceBlocked(cur.sub, on, activeId(st)))
1278
+ if (r.ok) {
1279
+ flash(st, on ? i.deviceBlockedMsg(cur.deviceId) : (r.v.was ? i.deviceUnblockedMsg(cur.deviceId) : i.deviceNotBlocked(cur.deviceId)))
1280
+ await refreshDevices(term, st)
1281
+ }
1260
1282
  } else if (ch === 'r' && cur) {
1261
1283
  // Renombrar: el nombre lo trae el aparato al emparejarse (y si no le diste uno, entra
1262
1284
  // con TU apodo de ese momento), así que a la semana ya no dice nada. `r` es renombrar
@@ -2298,8 +2320,11 @@ const helpSegs = (i, screen, st = {}) => {
2298
2320
  return segs({
2299
2321
  pending: !!st.pending,
2300
2322
  drafts: draftChanges(st).length > 0,
2301
- hasDevices: (st.devices?.issued || []).length > 0,
2323
+ // Las filas salen del ACTA (miembros con o sin certificado), así que la ayuda mira
2324
+ // la lista fundida: un miembro sin papel también se renombra, bloquea o quita.
2325
+ hasDevices: devs.length > 0,
2302
2326
  isService: !!cur?.cn,
2327
+ curBlocked: !!blockedOf(st, cur?.sub),
2303
2328
  hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
2304
2329
  hasVars: devVarsOf(st, st.varsFor?.pub).length > 0,
2305
2330
  hasLogins: (st.logins || []).length > 0,
@@ -2528,6 +2553,7 @@ export async function runTui () {
2528
2553
  scroll: {},
2529
2554
  profiles: null,
2530
2555
  devices: null,
2556
+ blocked: [],
2531
2557
  secrets: null,
2532
2558
  // Los inicios de sesión se piden al ENTRAR en su pestaña, no al arrancar.
2533
2559
  logins: null,
package/src/tui/i18n.js CHANGED
@@ -280,6 +280,14 @@ const es = {
280
280
  revokeConfirm: (id) => `¿Quitar ${id} del perfil? Pierde el acceso ahora mismo. Lo que tenga guardado se le borra cuando se conecte: si está apagado, sigue ahí hasta entonces.`,
281
281
  revoking: 'Revocando…',
282
282
  deviceRevoked: (id) => `Revocado ${id}`,
283
+ // BLOQUEAR sin quitar: sigue en la cuenta, pero nadie le atiende hasta desbloquearlo.
284
+ deviceBlocked: 'BLOQUEADO',
285
+ blocking: 'Bloqueando…',
286
+ unblocking: 'Desbloqueando…',
287
+ deviceBlockedMsg: (id) => `Bloqueado ${id}: la bóveda y tus máquinas no le atienden hasta que lo desbloquees (b)`,
288
+ deviceUnblockedMsg: (id) => `Desbloqueado ${id}`,
289
+ deviceNotBlocked: (id) => `${id} no estaba bloqueado`,
290
+ cantBlockMaster: 'La bóveda no se bloquea a sí misma.',
283
291
  approveLabel: (id) => `Código que MUESTRA el dispositivo ${id}`,
284
292
  approveHint: 'el vault no lo conoce: compáralo en la otra pantalla',
285
293
  codeMissing: 'Falta el código',
@@ -411,13 +419,13 @@ const es = {
411
419
  // Anunciar teclas muertas confunde y además las quema para otros usos.
412
420
  // `e variables` solo en un SERVICIO: es el único que las lee, así que en un teléfono
413
421
  // era una tecla que solo sabía decir que no.
414
- helpDevices: ({ pending, hasDevices, isService, drafts } = {}) => [
422
+ helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
415
423
  '←→ pestaña', '↑↓', 'p emparejar',
416
424
  ...(drafts ? ['g guardar permisos'] : []),
417
425
  ...(pending ? ['a aprobar', 'x rechazar'] : []),
418
426
  ...(hasDevices ? ['r renombrar', 'c permisos'] : []),
419
427
  ...(isService ? ['e variables'] : []),
420
- ...(hasDevices ? ['v revocar'] : []),
428
+ ...(hasDevices ? [curBlocked ? 'b desbloquear' : 'b bloquear', 'v revocar'] : []),
421
429
  'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
422
430
  ],
423
431
  renameDeviceLabel: (id) => `¿Cómo quieres llamar a ${id}?`,
@@ -718,6 +726,13 @@ const en = {
718
726
  revokeConfirm: (id) => `Remove ${id} from the profile? It loses access right away. What it has stored is erased when it connects: if it is switched off, it stays there until then.`,
719
727
  revoking: 'Revoking…',
720
728
  deviceRevoked: (id) => `Revoked ${id}`,
729
+ deviceBlocked: 'BLOCKED',
730
+ blocking: 'Blocking…',
731
+ unblocking: 'Unblocking…',
732
+ deviceBlockedMsg: (id) => `Blocked ${id}: the vault and your machines ignore it until you unblock it (b)`,
733
+ deviceUnblockedMsg: (id) => `Unblocked ${id}`,
734
+ deviceNotBlocked: (id) => `${id} was not blocked`,
735
+ cantBlockMaster: 'The vault does not block itself.',
721
736
  approveLabel: (id) => `Code SHOWN by device ${id}`,
722
737
  approveHint: 'the vault does not know it: compare it on the other screen',
723
738
  codeMissing: 'The code is missing',
@@ -827,13 +842,13 @@ const en = {
827
842
  helpConfirm: 'y confirm · n/Esc cancel',
828
843
 
829
844
  helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 'l Español', 'q quit'],
830
- helpDevices: ({ pending, hasDevices, isService, drafts } = {}) => [
845
+ helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
831
846
  '←→ tab', '↑↓', 'p pair',
832
847
  ...(drafts ? ['g save permissions'] : []),
833
848
  ...(pending ? ['a approve', 'x reject'] : []),
834
849
  ...(hasDevices ? ['r rename', 'c permissions'] : []),
835
850
  ...(isService ? ['e variables'] : []),
836
- ...(hasDevices ? ['v revoke'] : []),
851
+ ...(hasDevices ? [curBlocked ? 'b unblock' : 'b block', 'v revoke'] : []),
837
852
  'F5 refresh', 'Esc vaults', 'l Español', 'q quit'
838
853
  ],
839
854
  renameDeviceLabel: (id) => `What do you want to call ${id}?`,
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,
@@ -44,6 +44,9 @@ const F = {
44
44
  approveReq: 'approve-request.json',
45
45
  rejectReq: 'reject-request.json',
46
46
  revokeReq: 'revoke-request.json',
47
+ // BLOQUEAR / DESBLOQUEAR un aparato (sin tocar el acta). Mismo par que usa la CLI.
48
+ blockReq: 'block-request.json',
49
+ blockRes: 'block.json',
47
50
  labelReq: 'label-request.json',
48
51
  capsReq: 'caps-request.json',
49
52
  capsRes: 'caps.json',
@@ -394,6 +397,8 @@ export async function listDevices (profile) {
394
397
  issued: groupCertsByDevice(withIds, revoked),
395
398
  revoked,
396
399
  members: record?.members || [],
400
+ // LOS BLOQUEADOS viajan con el acta: quien pinte la lista los marca en su fila.
401
+ blocked: Array.isArray(record?.blocked) ? record.blocked : [],
397
402
  profile: devices.profile || null
398
403
  }
399
404
  }
@@ -502,6 +507,25 @@ export async function revokeDevice (target, profile) {
502
507
  return listDevices(profile)
503
508
  }
504
509
 
510
+ /**
511
+ * BLOQUEA o DESBLOQUEA un aparato por su llave `pub`. No toca el acta: sigue siendo
512
+ * miembro, pero la bóveda y los agentes dejan de atenderle (dueño, 2026-10-07). Funciona
513
+ * con el perfil cerrado, igual que `dotrino-vault block|unblock`. Devuelve `{ on, was }`:
514
+ * `was` dice si al desbloquear había algo que quitar.
515
+ */
516
+ export async function setDeviceBlocked (pub, on, profile) {
517
+ requireAlive()
518
+ if (typeof pub !== 'string' || !pub) throw coded('a device key is required', 'BAD_PUB')
519
+ rm(F.blockRes)
520
+ const since = Date.now()
521
+ const id = await writeReq(F.blockReq, { pub, on: !!on }, profile)
522
+ signalOrCleanup('SIGUSR2', [F.blockReq])
523
+ const r = await waitFor(F.blockRes, { req: id, since, tries: 40 })
524
+ if (!r) throw coded('the daemon did not reply', 'NO_REPLY')
525
+ if (r.ok === false) throw coded(r.error || `could not ${on ? 'block' : 'unblock'} the device`, r.code || 'BLOCK_FAILED')
526
+ return { on: !!on, was: !!r.was }
527
+ }
528
+
505
529
  // ---------------------------------------------------------------------------
506
530
  // Perfil del usuario (lo que sincronizan los dispositivos)
507
531
  // ---------------------------------------------------------------------------