@dotrino/vaultd 0.11.0 → 0.13.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/src/vault.js CHANGED
@@ -15,7 +15,8 @@ import path from 'node:path'
15
15
  import { Identity } from '@dotrino/identity/node'
16
16
  import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
17
17
  import * as Acta from '@dotrino/identity/acta'
18
- import { createEnrollDesk, deviceIdOf } from '../lib/src/enroll.js'
18
+ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
19
+ import { createAdminDesk } from '../lib/src/admin.js'
19
20
  import { createTransport, masterPubkeyOf } from './transport.js'
20
21
  import { openStore } from './store.js'
21
22
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
@@ -37,14 +38,16 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
37
38
  */
38
39
  export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
39
40
  ensureDir(dir)
40
- // CIFRADO EN REPOSO ligado a esta máquina: `identity.json` deja de estar en claro, así
41
- // que copiarlo a otro equipo no sirve de nada. No protege contra quien ya tiene ESTA
41
+ // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
42
+ // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
43
+ // antes de reemplazar); el resto —`vault.json`, `threads.json`, `secrets.json`— lo hace
44
+ // su propio store al abrirse. No protege contra quien ya tiene ESTA
42
45
  // máquina (puede leer el mismo material); es subir el listón, no una imposibilidad.
43
46
  // La migración verifica antes de reemplazar: si algo falla, el original queda intacto.
44
47
  try {
45
48
  const r = migrateFile(path.join(dir, 'identity.json'), machineKey(dir))
46
- if (r === 'migrado') log('[vault] identidad cifrada en reposo (ligada a esta máquina)')
47
- } catch (e) { log('[vault] no se pudo cifrar la identidad en reposo:', e.message) }
49
+ if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
50
+ } catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
48
51
  const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
49
52
  if (!identity.me?.publickey) await identity.setMyNickname('')
50
53
  // CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
@@ -52,7 +55,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
52
55
  // acepte cambiar su acta recién nacida por la que traiga el dispositivo. Sin la marca,
53
56
  // adoptar sería pisar una cuenta con datos y se rechaza — que es lo correcto por defecto.
54
57
  if (forAdoption) {
55
- try { await identity.prepareForAdoption() } catch (e) { log('[vault] no se pudo preparar el perfil para adoptar:', e.message) }
58
+ try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
56
59
  }
57
60
 
58
61
  const store = openStore(dir)
@@ -69,7 +72,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
69
72
  }
70
73
 
71
74
  const reply = (to, obj) => {
72
- try { client.send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) }
75
+ try { client.send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) }
73
76
  }
74
77
 
75
78
  // FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
@@ -94,7 +97,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
94
97
 
95
98
  const FRESH_WINDOW_MS = 5 * 60 * 1000
96
99
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
97
- const staleReply = (from) => reply(from, { type: MSG.ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
100
+ const staleReply = (from) => reply(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
98
101
 
99
102
  // --- ENROLL / aprobación / revocación: núcleo COMPARTIDO (lib/src/enroll.js) ---
100
103
  // El mismo módulo lo usan «este dispositivo es bóveda» (@dotrino/vault) y la copia
@@ -113,8 +116,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
113
116
  // ella entraría mandando una cuenta que no puede abrir.
114
117
  encPub: identity.me?.encryptionPubkey || null,
115
118
  vaultLabel: 'bóveda',
116
- // Nuestra dirección en el proxy: lo único que lleva el QR corto.
117
- connToken: () => client.token,
119
+ // Lo único que lleva el QR corto: una CITA del proxio, que es un código de 6
120
+ // caracteres de un solo uso y con minutos de vida. Antes iba la dirección de
121
+ // la conexión, que eran 4 caracteres; hoy esa dirección es una instancia de
122
+ // 24 (para poder rutearla entre proxios) y no cabe cómoda en un QR ni
123
+ // conviene dejarla impresa en algo que circula. Se pide una por
124
+ // emparejamiento: si el proxio es viejo y no las conoce, se cae solo a la
125
+ // invitación larga, que sigue funcionando.
126
+ connToken: async () => {
127
+ try { return (await client.requestPairingCode())?.code || null }
128
+ catch (_) { return null }
129
+ },
118
130
  onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
119
131
  defaultScope: [SCOPE.READ],
120
132
  onChallenge ({ deviceId, scope }) {
@@ -133,9 +145,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
133
145
  data: p.data, signature: p.signature, cert: p.cert,
134
146
  expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
135
147
  })
136
- if (!chk.ok) { audit('rejected', { what: 'sign', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
148
+ if (!chk.ok) { audit('rejected', { what: 'sign', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
137
149
  const toSign = p.data?.payload
138
- if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload requerido' })
150
+ if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload required' })
139
151
  const { signature, publickey } = await identity.signData(toSign)
140
152
  audit('sign', { device: await deviceIdOf(chk.device) })
141
153
  reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
@@ -147,7 +159,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
147
159
  data: p.data, signature: p.signature, cert: p.cert,
148
160
  expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
149
161
  })
150
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
162
+ if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
151
163
  const id = p.data?.id || 'root'
152
164
  reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
153
165
  }
@@ -156,8 +168,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
156
168
  // aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
157
169
  async function handleStore (from, p) {
158
170
  const d = p.data
159
- if (!d || typeof d.method !== 'string' || !threads.methods[d.method]) {
160
- return reply(from, { type: MSG.ERROR, error: 'store: método inválido' })
171
+ // `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
172
+ // `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
173
+ // se llamaba como si fuera del store.
174
+ if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
175
+ return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
161
176
  }
162
177
  if (!isFresh(d)) return staleReply(from)
163
178
  // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
@@ -165,14 +180,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
165
180
  // lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
166
181
  if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
167
182
  audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
168
- return reply(from, { type: MSG.ERROR, error: 'perfil bloqueado: desbloquéalo en el PC del vault (dotrino-vault unlock) para editarlo' })
183
+ return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
169
184
  }
170
185
  const revoked = await revocationSet()
171
186
  let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, trustedIssuer: master, revoked })
172
187
  if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
173
188
  chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
174
189
  }
175
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
190
+ if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
176
191
  try {
177
192
  // CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
178
193
  // pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
@@ -181,7 +196,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
181
196
  let cek = null
182
197
  if (d.enc) {
183
198
  cek = await identity.contentKey?.().catch(() => null)
184
- if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: esta bóveda no tiene la clave de contenido del perfil' })
199
+ if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key' })
185
200
  args = JSON.parse(await identity.openContent(d.enc))
186
201
  }
187
202
  const result = await threads.methods[d.method](args)
@@ -198,7 +213,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
198
213
  async function handleDevices (from, p) {
199
214
  if (!isFresh(p.data)) return staleReply(from)
200
215
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
201
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
216
+ if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
202
217
  const { issued, revoked } = await identity.listDelegations()
203
218
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
204
219
  // política (quién manda, quién puede qué) sin un canal aparte.
@@ -227,13 +242,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
227
242
  async function handleRenew (from, p) {
228
243
  if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
229
244
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
230
- if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
245
+ if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
231
246
  // Reusar el label del cert original (si sigue registrado en delegations).
232
247
  const { issued } = await identity.listDelegations()
233
248
  const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
234
- const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
235
- audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '' })
236
- log(`[vault] cert renovado para ${await deviceIdOf(p.cert.sub)} (30 días)`)
249
+ // EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
250
+ // decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
251
+ // Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: dar
252
+ // `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
253
+ // QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
254
+ // Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
255
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
256
+ let scope = p.cert.scope
257
+ if (acta) {
258
+ scope = Acta.memberScopes(acta, p.cert.sub)
259
+ if (!scope.length) {
260
+ audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
261
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
262
+ }
263
+ }
264
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
265
+ audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope })
266
+ log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} (30 days)`)
237
267
  reply(from, { type: MSG.RENEWED, cert })
238
268
  }
239
269
 
@@ -247,26 +277,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
247
277
  async function handleSecrets (from, p) {
248
278
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
249
279
  const ns = p.data?.ns
250
- if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: namespace inválido' })
251
- if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: falta ek (llave efímera del solicitante)' })
280
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
281
+ if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
252
282
  const chk = await verifyChain({
253
283
  data: p.data, signature: p.signature, cert: p.cert,
254
284
  expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
255
285
  })
256
- if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
286
+ if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
257
287
  // FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
258
288
  // miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
259
289
  // llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
260
290
  const acta = (await identity.profileActa?.().catch(() => null))?.acta
261
291
  if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
262
292
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
263
- return reply(from, { type: MSG.ERROR, error: `no autorizado: cn — el acta no reconoce a este miembro como el servicio «${ns}»` })
293
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
264
294
  }
265
295
  let enc
266
296
  try {
267
297
  enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
268
298
  } catch (e) {
269
- return reply(from, { type: MSG.ERROR, error: 'secrets: ek inválida' })
299
+ return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
270
300
  }
271
301
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
272
302
  const { signature } = await identity.signData(body)
@@ -286,6 +316,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
286
316
  if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
287
317
  if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
288
318
  if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
319
+ if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
289
320
  } catch (e) {
290
321
  reply(from, { type: MSG.ERROR, error: e.message })
291
322
  }
@@ -296,9 +327,133 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
296
327
  // ----- API local (CLI/UI de control) -----
297
328
  // Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
298
329
 
330
+ // ----- AVISO DE CAMBIO a los agentes del ns -----
331
+ //
332
+ // Guardar un secreto no sirve de nada si quien lo usa no se entera. La bóveda
333
+ // avisa (sin mandar valores: solo «el ns cambió») y el agente decide — el
334
+ // estándar es que SALGA y lo levante su supervisor, para leer todo fresco y,
335
+ // sobre todo, para que el valor viejo deje de existir en su memoria.
336
+ //
337
+ // AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
338
+ // escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
339
+ // reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
340
+ const AVISO_AGRUPA_MS = Number(process.env.DOTRINO_VAULT_AVISO_MS) || 3000
341
+ const avisosPendientes = new Map() // ns → timer
342
+
343
+ async function avisarCambio (ns) {
344
+ let destinos = []
345
+ try {
346
+ const { issued } = await identity.listDelegations()
347
+ const revocados = await revocationSet()
348
+ const scope = secretsScope(ns)
349
+ // Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
350
+ // que mandárselo a otro sería filtrarle que existe.
351
+ //
352
+ // Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
353
+ // delegación nueva para la MISMA sub-clave, así que un agente que lleve
354
+ // tiempo enrolado aparece varias veces y recibiría el aviso repetido.
355
+ const vistas = new Set()
356
+ destinos = (issued || []).filter((x) => {
357
+ if (!x.sub || revocados.has(x.nonce) || !(x.scope || []).includes(scope)) return false
358
+ if (vistas.has(x.sub)) return false
359
+ vistas.add(x.sub)
360
+ return true
361
+ })
362
+ } catch (e) { return log('[vault] could not list who to notify:', e.message) }
363
+ if (!destinos.length) return
364
+
365
+ const body = { op: 'secrets.changed', ns, ts: Date.now() }
366
+ const { signature } = await identity.signData(body)
367
+ for (const d of destinos) {
368
+ try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
369
+ }
370
+ audit('secrets.changed', { ns, avisados: destinos.length })
371
+ log(`[vault] config for "${ns}" changed: notified ${destinos.length} agent(s)`)
372
+ }
373
+
374
+ function programarAviso (ns) {
375
+ clearTimeout(avisosPendientes.get(ns))
376
+ const t = setTimeout(() => {
377
+ avisosPendientes.delete(ns)
378
+ avisarCambio(ns).catch((e) => log('[vault] change notice failed:', e.message))
379
+ }, AVISO_AGRUPA_MS)
380
+ t.unref?.()
381
+ avisosPendientes.set(ns, t)
382
+ }
383
+
384
+ // --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
385
+ // Un dispositivo con cert `vault:admin` puede ADMITIR y EXPULSAR miembros sin venir
386
+ // al PC. No puede cambiar permisos, traspasar el mando, conceder `admin` ni tocar los
387
+ // secretos: esas operaciones no existen como mensaje, a propósito. Así un aparato con
388
+ // `admin` robado hace daño acotado y reversible (se le revoca) en vez de poder dejar
389
+ // al dueño fuera de su propia cuenta, que no tiene vuelta atrás.
390
+ /** Últimas entradas de la bitácora (JSONL), de la más reciente hacia atrás. */
391
+ function readActivity (limit = 100) {
392
+ try {
393
+ return fs.readFileSync(activityFile, 'utf8').split('\n').filter(Boolean).slice(-limit)
394
+ .map((l) => { try { return JSON.parse(l) } catch (_) { return null } }).filter(Boolean).reverse()
395
+ } catch (_) { return [] }
396
+ }
397
+
398
+ /**
399
+ * Avisa a TODOS los miembros de que el perfil cambió, firmado por la maestra. Sin
400
+ * esto, administrar a distancia sería invisible para el resto de los dispositivos —
401
+ * y esa visibilidad es lo que hace DETECTABLE a un admin comprometido. Va por
402
+ * `sendByPubkey`, así que al que está apagado le llega cuando encienda (cola 24 h).
403
+ */
404
+ async function notifyMembers (ev, info = {}) {
405
+ try {
406
+ const body = { ev, ...info, ts: Date.now() }
407
+ const { signature } = await identity.signData(body)
408
+ const { issued } = await identity.listDelegations()
409
+ // UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
410
+ // MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
411
+ // y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
412
+ // cuidado que en `avisarCambio`.
413
+ const vistas = new Set()
414
+ for (const d of issued || []) {
415
+ if (!d.sub || vistas.has(d.sub)) continue
416
+ vistas.add(d.sub)
417
+ try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
418
+ }
419
+ } catch (e) { log('[vault] could not notify members of the change:', e.message) }
420
+ }
421
+
422
+ const admin = createAdminDesk({
423
+ desk,
424
+ deviceIdOf,
425
+ ttlMs: DEVICE_TTL_MS,
426
+ audit,
427
+ notify: notifyMembers,
428
+ readActivity,
429
+ // CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
430
+ // qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
431
+ // hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
432
+ // fiar exigía revocarlo entero. Con el cruce, deja de administrar en el acto.
433
+ verify: async ({ data, signature, cert }) => {
434
+ const chk = await verifyChain({
435
+ data, signature, cert,
436
+ expectedScope: SCOPE.ADMIN, trustedIssuer: master, revoked: await revocationSet()
437
+ })
438
+ if (!chk.ok) return chk
439
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
440
+ if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
441
+ return chk
442
+ }
443
+ })
444
+
445
+ async function handleAdmin (from, p) {
446
+ // La frescura se comprueba aquí (es del transporte, igual que en el resto de
447
+ // handlers); el resto de la regla vive en el módulo puro.
448
+ if (!isFresh(p.data)) return staleReply(from)
449
+ const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
450
+ if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error })
451
+ reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
452
+ }
453
+
299
454
  // API local de secretos (solo CLI/UI del dueño; audita cada cambio).
300
- function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }) }
301
- function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) audit('secret.rm', { ns, key }); return ok }
455
+ function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }); programarAviso(ns) }
456
+ function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); programarAviso(ns) } return ok }
302
457
  function listSecrets () { return secrets.list() }
303
458
 
304
459
  return {
@@ -306,7 +461,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
306
461
  startPairing: desk.startPairing,
307
462
  stopPairing: desk.stopPairing,
308
463
  listPending: desk.listPending,
309
- approveDevice: (code) => desk.approve(code),
464
+ // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
465
+ // dispositivos se entera de que entró alguien, venga de donde venga.
466
+ approveDevice: async (code) => {
467
+ const r = await desk.approve(code)
468
+ await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
469
+ return r
470
+ },
310
471
  rejectDevice: (deviceId) => desk.reject(deviceId),
311
472
  setSecret, deleteSecret, listSecrets,
312
473
  listDevices: () => identity.listDelegations(),
@@ -315,8 +476,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
315
476
  profileMembers: () => identity.profileMembers(),
316
477
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
317
478
  isMaster: () => identity.isMaster(),
318
- setCaps: (pub, caps) => identity.setCaps(pub, caps),
319
- revokeDevice: (nonce) => desk.revoke(nonce),
320
- close () { try { client.close() } catch (_) {} identity.destroy() }
479
+ setCaps: async (pub, caps) => {
480
+ const r = await identity.setCaps(pub, caps)
481
+ audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
482
+ await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
483
+ return r
484
+ },
485
+ revokeDevice: async (nonce) => {
486
+ const r = await desk.revoke(nonce)
487
+ await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
488
+ return r
489
+ },
490
+ close () {
491
+ for (const t of avisosPendientes.values()) clearTimeout(t)
492
+ avisosPendientes.clear()
493
+ try { client.close() } catch (_) {} identity.destroy()
494
+ }
321
495
  }
322
496
  }
@@ -149,7 +149,7 @@ async function profileOp (op, { profile, name, password } = {}) {
149
149
  writeReq(F.profileReq, { op, ...extra }, profile)
150
150
  signalOrCleanup('SIGUSR2', [F.profileReq])
151
151
  const d = await waitFor(F.profilesList)
152
- if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
152
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
153
153
  if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
154
154
  return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
155
155
  }
@@ -189,7 +189,7 @@ export async function snapshot (profile) {
189
189
  */
190
190
  export async function listDevices (profile) {
191
191
  const { devices } = await snapshot(profile)
192
- if (!devices) throw coded('el daemon no respondió', 'NO_REPLY')
192
+ if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
193
193
  const issued = devices.issued || devices.active || devices.delegations || []
194
194
  const withIds = await Promise.all(issued.map(async (d) => ({
195
195
  ...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
@@ -213,7 +213,7 @@ export async function revokeDevice (nonce, profile) {
213
213
  /** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
214
214
  export async function listSecrets (profile) {
215
215
  const { secrets } = await snapshot(profile)
216
- if (!secrets) throw coded('el daemon no respondió', 'NO_REPLY')
216
+ if (!secrets) throw coded('the daemon did not reply', 'NO_REPLY')
217
217
  return secrets.ns || {}
218
218
  }
219
219
 
@@ -225,8 +225,8 @@ export async function setSecret (ns, key, value, profile) {
225
225
  writeReq(F.dumpReq, {}, profile)
226
226
  signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
227
227
  const d = await waitFor(F.secretsList)
228
- if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
229
- if (!(d.ns?.[ns] || []).includes(key)) throw coded('el daemon no aplicó el cambio (revisa los logs del servicio)', 'NOT_APPLIED')
228
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
229
+ if (!(d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
230
230
  return d.ns
231
231
  }
232
232
 
@@ -238,8 +238,8 @@ export async function deleteSecret (ns, key, profile) {
238
238
  writeReq(F.dumpReq, {}, profile)
239
239
  signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
240
240
  const d = await waitFor(F.secretsList)
241
- if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
242
- if ((d.ns?.[ns] || []).includes(key)) throw coded('el daemon no borró la variable (revisa los logs del servicio)', 'NOT_DELETED')
241
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
242
+ if ((d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
243
243
  return d.ns
244
244
  }
245
245
 
@@ -292,7 +292,7 @@ export async function startPairing ({ profile, service } = {}) {
292
292
  return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload, code, b64: code, profile: pr.profile || null, profileName: pr.profileName || '' }
293
293
  }
294
294
  }
295
- throw coded('el daemon no inició el emparejamiento', 'PAIR_FAILED')
295
+ throw coded('the daemon did not start the pairing', 'PAIR_FAILED')
296
296
  }
297
297
 
298
298
  /** Dispositivo pendiente de aprobar (el que se conectó con el QR), o null. */