@dotrino/vaultd 0.12.0 → 0.14.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
@@ -142,9 +145,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
142
145
  data: p.data, signature: p.signature, cert: p.cert,
143
146
  expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
144
147
  })
145
- 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 }) }
146
149
  const toSign = p.data?.payload
147
- 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' })
148
151
  const { signature, publickey } = await identity.signData(toSign)
149
152
  audit('sign', { device: await deviceIdOf(chk.device) })
150
153
  reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
@@ -156,7 +159,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
156
159
  data: p.data, signature: p.signature, cert: p.cert,
157
160
  expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
158
161
  })
159
- 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 })
160
163
  const id = p.data?.id || 'root'
161
164
  reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
162
165
  }
@@ -165,8 +168,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
165
168
  // aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
166
169
  async function handleStore (from, p) {
167
170
  const d = p.data
168
- if (!d || typeof d.method !== 'string' || !threads.methods[d.method]) {
169
- 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' })
170
176
  }
171
177
  if (!isFresh(d)) return staleReply(from)
172
178
  // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
@@ -174,14 +180,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
174
180
  // lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
175
181
  if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
176
182
  audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
177
- 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' })
178
184
  }
179
185
  const revoked = await revocationSet()
180
186
  let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, trustedIssuer: master, revoked })
181
187
  if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
182
188
  chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
183
189
  }
184
- 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 })
185
191
  try {
186
192
  // CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
187
193
  // pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
@@ -190,10 +196,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
190
196
  let cek = null
191
197
  if (d.enc) {
192
198
  cek = await identity.contentKey?.().catch(() => null)
193
- 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' })
194
200
  args = JSON.parse(await identity.openContent(d.enc))
195
201
  }
196
202
  const result = await threads.methods[d.method](args)
203
+ // Que un aparato ESCRIBA en tu bóveda queda anotado. Antes solo se auditaba el
204
+ // rechazo, así que la bitácora contaba quién entró pero no qué hizo después.
205
+ // Solo la operación y el aparato: nunca el contenido (`activity` es un registro
206
+ // de seguridad, no una copia de lo que guardas).
207
+ if (!STORE_READ_METHODS.has(d.method)) {
208
+ audit('store', { device: await deviceIdOf(chk.device), method: d.method })
209
+ }
197
210
  if (cek) {
198
211
  const enc = await identity.sealContent(JSON.stringify(result ?? null))
199
212
  return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
@@ -207,7 +220,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
207
220
  async function handleDevices (from, p) {
208
221
  if (!isFresh(p.data)) return staleReply(from)
209
222
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
210
- if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
223
+ if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
211
224
  const { issued, revoked } = await identity.listDelegations()
212
225
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
213
226
  // política (quién manda, quién puede qué) sin un canal aparte.
@@ -236,13 +249,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
236
249
  async function handleRenew (from, p) {
237
250
  if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
238
251
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
239
- if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
252
+ if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
240
253
  // Reusar el label del cert original (si sigue registrado en delegations).
241
254
  const { issued } = await identity.listDelegations()
242
255
  const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
243
- const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
244
- audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '' })
245
- log(`[vault] cert renovado para ${await deviceIdOf(p.cert.sub)} (30 días)`)
256
+ // EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
257
+ // decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
258
+ // Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: dar
259
+ // `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
260
+ // QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
261
+ // Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
262
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
263
+ let scope = p.cert.scope
264
+ if (acta) {
265
+ scope = Acta.memberScopes(acta, p.cert.sub)
266
+ if (!scope.length) {
267
+ audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
268
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
269
+ }
270
+ }
271
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
272
+ audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope })
273
+ log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} (30 days)`)
246
274
  reply(from, { type: MSG.RENEWED, cert })
247
275
  }
248
276
 
@@ -256,26 +284,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
256
284
  async function handleSecrets (from, p) {
257
285
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
258
286
  const ns = p.data?.ns
259
- if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: namespace inválido' })
260
- if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: falta ek (llave efímera del solicitante)' })
287
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
288
+ if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
261
289
  const chk = await verifyChain({
262
290
  data: p.data, signature: p.signature, cert: p.cert,
263
291
  expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
264
292
  })
265
- if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason }) }
293
+ if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
266
294
  // FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
267
295
  // miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
268
296
  // llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
269
297
  const acta = (await identity.profileActa?.().catch(() => null))?.acta
270
298
  if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
271
299
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
272
- return reply(from, { type: MSG.ERROR, error: `no autorizado: cn — el acta no reconoce a este miembro como el servicio «${ns}»` })
300
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
273
301
  }
274
302
  let enc
275
303
  try {
276
304
  enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
277
305
  } catch (e) {
278
- return reply(from, { type: MSG.ERROR, error: 'secrets: ek inválida' })
306
+ return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
279
307
  }
280
308
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
281
309
  const { signature } = await identity.signData(body)
@@ -295,6 +323,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
295
323
  if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
296
324
  if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
297
325
  if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
326
+ if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
298
327
  } catch (e) {
299
328
  reply(from, { type: MSG.ERROR, error: e.message })
300
329
  }
@@ -305,9 +334,133 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
305
334
  // ----- API local (CLI/UI de control) -----
306
335
  // Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
307
336
 
337
+ // ----- AVISO DE CAMBIO a los agentes del ns -----
338
+ //
339
+ // Guardar un secreto no sirve de nada si quien lo usa no se entera. La bóveda
340
+ // avisa (sin mandar valores: solo «el ns cambió») y el agente decide — el
341
+ // estándar es que SALGA y lo levante su supervisor, para leer todo fresco y,
342
+ // sobre todo, para que el valor viejo deje de existir en su memoria.
343
+ //
344
+ // AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
345
+ // escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
346
+ // reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
347
+ const AVISO_AGRUPA_MS = Number(process.env.DOTRINO_VAULT_AVISO_MS) || 3000
348
+ const avisosPendientes = new Map() // ns → timer
349
+
350
+ async function avisarCambio (ns) {
351
+ let destinos = []
352
+ try {
353
+ const { issued } = await identity.listDelegations()
354
+ const revocados = await revocationSet()
355
+ const scope = secretsScope(ns)
356
+ // Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
357
+ // que mandárselo a otro sería filtrarle que existe.
358
+ //
359
+ // Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
360
+ // delegación nueva para la MISMA sub-clave, así que un agente que lleve
361
+ // tiempo enrolado aparece varias veces y recibiría el aviso repetido.
362
+ const vistas = new Set()
363
+ destinos = (issued || []).filter((x) => {
364
+ if (!x.sub || revocados.has(x.nonce) || !(x.scope || []).includes(scope)) return false
365
+ if (vistas.has(x.sub)) return false
366
+ vistas.add(x.sub)
367
+ return true
368
+ })
369
+ } catch (e) { return log('[vault] could not list who to notify:', e.message) }
370
+ if (!destinos.length) return
371
+
372
+ const body = { op: 'secrets.changed', ns, ts: Date.now() }
373
+ const { signature } = await identity.signData(body)
374
+ for (const d of destinos) {
375
+ try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
376
+ }
377
+ audit('secrets.changed', { ns, avisados: destinos.length })
378
+ log(`[vault] config for "${ns}" changed: notified ${destinos.length} agent(s)`)
379
+ }
380
+
381
+ function programarAviso (ns) {
382
+ clearTimeout(avisosPendientes.get(ns))
383
+ const t = setTimeout(() => {
384
+ avisosPendientes.delete(ns)
385
+ avisarCambio(ns).catch((e) => log('[vault] change notice failed:', e.message))
386
+ }, AVISO_AGRUPA_MS)
387
+ t.unref?.()
388
+ avisosPendientes.set(ns, t)
389
+ }
390
+
391
+ // --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
392
+ // Un dispositivo con cert `vault:admin` puede ADMITIR y EXPULSAR miembros sin venir
393
+ // al PC. No puede cambiar permisos, traspasar el mando, conceder `admin` ni tocar los
394
+ // secretos: esas operaciones no existen como mensaje, a propósito. Así un aparato con
395
+ // `admin` robado hace daño acotado y reversible (se le revoca) en vez de poder dejar
396
+ // al dueño fuera de su propia cuenta, que no tiene vuelta atrás.
397
+ /** Últimas entradas de la bitácora (JSONL), de la más reciente hacia atrás. */
398
+ function readActivity (limit = 100) {
399
+ try {
400
+ return fs.readFileSync(activityFile, 'utf8').split('\n').filter(Boolean).slice(-limit)
401
+ .map((l) => { try { return JSON.parse(l) } catch (_) { return null } }).filter(Boolean).reverse()
402
+ } catch (_) { return [] }
403
+ }
404
+
405
+ /**
406
+ * Avisa a TODOS los miembros de que el perfil cambió, firmado por la maestra. Sin
407
+ * esto, administrar a distancia sería invisible para el resto de los dispositivos —
408
+ * y esa visibilidad es lo que hace DETECTABLE a un admin comprometido. Va por
409
+ * `sendByPubkey`, así que al que está apagado le llega cuando encienda (cola 24 h).
410
+ */
411
+ async function notifyMembers (ev, info = {}) {
412
+ try {
413
+ const body = { ev, ...info, ts: Date.now() }
414
+ const { signature } = await identity.signData(body)
415
+ const { issued } = await identity.listDelegations()
416
+ // UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
417
+ // MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
418
+ // y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
419
+ // cuidado que en `avisarCambio`.
420
+ const vistas = new Set()
421
+ for (const d of issued || []) {
422
+ if (!d.sub || vistas.has(d.sub)) continue
423
+ vistas.add(d.sub)
424
+ try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
425
+ }
426
+ } catch (e) { log('[vault] could not notify members of the change:', e.message) }
427
+ }
428
+
429
+ const admin = createAdminDesk({
430
+ desk,
431
+ deviceIdOf,
432
+ ttlMs: DEVICE_TTL_MS,
433
+ audit,
434
+ notify: notifyMembers,
435
+ readActivity,
436
+ // CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
437
+ // qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
438
+ // hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
439
+ // fiar exigía revocarlo entero. Con el cruce, deja de administrar en el acto.
440
+ verify: async ({ data, signature, cert }) => {
441
+ const chk = await verifyChain({
442
+ data, signature, cert,
443
+ expectedScope: SCOPE.ADMIN, trustedIssuer: master, revoked: await revocationSet()
444
+ })
445
+ if (!chk.ok) return chk
446
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
447
+ if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
448
+ return chk
449
+ }
450
+ })
451
+
452
+ async function handleAdmin (from, p) {
453
+ // La frescura se comprueba aquí (es del transporte, igual que en el resto de
454
+ // handlers); el resto de la regla vive en el módulo puro.
455
+ if (!isFresh(p.data)) return staleReply(from)
456
+ const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
457
+ if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error })
458
+ reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
459
+ }
460
+
308
461
  // API local de secretos (solo CLI/UI del dueño; audita cada cambio).
309
- function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }) }
310
- function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) audit('secret.rm', { ns, key }); return ok }
462
+ function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }); programarAviso(ns) }
463
+ function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); programarAviso(ns) } return ok }
311
464
  function listSecrets () { return secrets.list() }
312
465
 
313
466
  return {
@@ -315,7 +468,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
315
468
  startPairing: desk.startPairing,
316
469
  stopPairing: desk.stopPairing,
317
470
  listPending: desk.listPending,
318
- approveDevice: (code) => desk.approve(code),
471
+ // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
472
+ // dispositivos se entera de que entró alguien, venga de donde venga.
473
+ approveDevice: async (code) => {
474
+ const r = await desk.approve(code)
475
+ await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
476
+ return r
477
+ },
319
478
  rejectDevice: (deviceId) => desk.reject(deviceId),
320
479
  setSecret, deleteSecret, listSecrets,
321
480
  listDevices: () => identity.listDelegations(),
@@ -324,8 +483,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
324
483
  profileMembers: () => identity.profileMembers(),
325
484
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
326
485
  isMaster: () => identity.isMaster(),
327
- setCaps: (pub, caps) => identity.setCaps(pub, caps),
328
- revokeDevice: (nonce) => desk.revoke(nonce),
329
- close () { try { client.close() } catch (_) {} identity.destroy() }
486
+ setCaps: async (pub, caps) => {
487
+ const r = await identity.setCaps(pub, caps)
488
+ audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
489
+ await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
490
+ return r
491
+ },
492
+ revokeDevice: async (nonce) => {
493
+ const r = await desk.revoke(nonce)
494
+ await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
495
+ return r
496
+ },
497
+ close () {
498
+ for (const t of avisosPendientes.values()) clearTimeout(t)
499
+ avisosPendientes.clear()
500
+ try { client.close() } catch (_) {} identity.destroy()
501
+ }
330
502
  }
331
503
  }
@@ -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. */