@dotrino/vaultd 0.26.2 → 0.46.2

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
@@ -13,7 +13,7 @@
13
13
  import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
  import { Identity } from '@dotrino/identity/node'
16
- import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
16
+ import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
17
17
  import * as Acta from '@dotrino/identity/acta'
18
18
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
19
19
  import { createAdminDesk } from '../lib/src/admin.js'
@@ -21,7 +21,9 @@ import { shouldNotifyRevoked } from '../lib/src/revocation.js'
21
21
  import { createTransport, masterPubkeyOf } from './transport.js'
22
22
  import { openStore } from './store.js'
23
23
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
24
- import { openSecretsStore } from './secretsStore.js'
24
+ import { openSecretsStore, assertVar } from './secretsStore.js'
25
+ import { makeSealer } from './sealer.js'
26
+ import { openSealKeys } from './sealKey.js'
25
27
  import { seal } from '../lib/src/sealed.js'
26
28
  import { dataDir, ensureDir } from './paths.js'
27
29
  import { atRestFor, machineKey, migrateFile } from './atrest.js'
@@ -37,7 +39,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
37
39
  * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
38
40
  * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
39
41
  */
40
- export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
42
+ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
41
43
  ensureDir(dir)
42
44
  // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
43
45
  // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
@@ -59,12 +61,60 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
59
61
  try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
60
62
  }
61
63
 
64
+ // LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
65
+ // abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
66
+ // y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
67
+ const sealKeys = openSealKeys(dir)
68
+ identity.setSealKeyProvider?.(() => sealKeys.mint())
69
+
62
70
  const store = openStore(dir)
63
71
  const threads = openThreadStore(dir)
64
- const secrets = openSecretsStore(dir)
72
+ const secrets = openSecretsStore(dir, {
73
+ sealer: makeSealer(),
74
+ // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
75
+ // propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
76
+ // acta, y por eso lo pone el vault: el store no conoce el acta.
77
+ recipients: (owner) => recipientsOf(owner),
78
+ // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
79
+ signer: (body) => signSeal(body),
80
+ defaultKey: () => new Uint8Array(machineKey(dir))
81
+ })
82
+ // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
83
+ // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
84
+ // con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
85
+ // siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
86
+ // Se dice en voz alta: prometer una protección que no está puesta es peor que no
87
+ // tenerla (docs/secretos-sellados.md §2.3).
88
+ try {
89
+ if (!hasPassword()) {
90
+ log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
91
+ log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
92
+ }
93
+ } catch (_) {}
94
+
65
95
  const master = await masterPubkeyOf(identity)
66
96
  const fp = (await pubkeyId(master)).slice(0, 16)
67
97
 
98
+ /**
99
+ * El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
100
+ * (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
101
+ * restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
102
+ * máquina y no puede quedar a medias.
103
+ *
104
+ * Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
105
+ * llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
106
+ */
107
+ try {
108
+ const info = await identity.profileActa?.()
109
+ const acta = info?.acta
110
+ if (info?.isMaster && acta && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
111
+ const r = await identity.rotateSealKey()
112
+ // Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
113
+ // primero y `console.log` no lo sustituye (salía «record #%s 2»).
114
+ log(`[vault] new sealing key in record #${r.seq}`)
115
+ }
116
+ } catch (e) { log('[vault] could not set up the sealing key:', e.message) }
117
+
68
118
  const { client } = await createTransport({ identity, dir, url: proxyUrl })
69
119
 
70
120
  async function revocationSet () {
@@ -129,6 +179,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
129
179
  catch (_) { return null }
130
180
  },
131
181
  onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
182
+ // Se va el aparato, se van SUS variables. Guardarlas sería configuración de una llave
183
+ // que ya no entra, y volvería a la vida sola el día que se enrole otro aparato con esa
184
+ // misma llave. Va aquí porque a quitar se entra por dos puertas (el PC y la consola
185
+ // remota) y las dos pasan por `desk.revokeDevice`.
186
+ onDeviceRemoved: (sub) => {
187
+ try {
188
+ const n = secrets.forgetDevice(sub)
189
+ if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
190
+ } catch (e) { log('[vault] could not drop the device variables:', e.message) }
191
+ // Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
192
+ // a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
193
+ // la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
194
+ //
195
+ // Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
196
+ // emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
197
+ // Un interruptor que pide una frase que quizá no tienes a mano no es un
198
+ // interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
199
+ markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
200
+ },
132
201
  defaultScope: [SCOPE.READ],
133
202
  onChallenge ({ deviceId, scope }) {
134
203
  log(`\n[vault] Un dispositivo quiere conectarse:`)
@@ -273,6 +342,37 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
273
342
  return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
274
343
  }
275
344
 
345
+ /**
346
+ * «¿SIGO SIENDO DE ESTA CASA?» — la única pregunta que se atiende SIN certificado.
347
+ *
348
+ * Existe por el aparato que se quedó sin papel: no puede firmar, ni leer, ni renovar, y
349
+ * —esto es lo grave— tampoco tenía forma de enterarse de que lo echaron, porque todo lo
350
+ * demás exige el certificado que ya no tiene. Se quedaba enseñando para siempre una
351
+ * cuenta que ya no era suya. Va firmada con la llave del propio aparato, que es
352
+ * exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
353
+ *
354
+ * La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
355
+ * pidió, y contarle el perfil entero a quien no trae papel es dar de más—. Al que ya no
356
+ * está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
357
+ * Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
358
+ * nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
359
+ * el aviso nombre al aparato que lo recibe).
360
+ */
361
+ async function handleCheck (from, p) {
362
+ if (!isFresh(p?.data)) return staleReply(from)
363
+ const pub = p.data.publickey
364
+ if (typeof pub !== 'string') return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
365
+ if (!(await verifyDeviceSig({ publickey: pub, data: p.data, signature: p.signature }))) {
366
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
367
+ }
368
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
369
+ const inside = (record?.members || []).some((m) => m?.pub === pub)
370
+ audit('check', { device: await deviceIdOf(pub).catch(() => null), in: inside })
371
+ if (inside) return reply(from, { type: MSG.CHECKED, in: true })
372
+ await notifyIfRevoked(pub, null, null, 'revoked')
373
+ reply(from, { type: MSG.CHECKED, in: false })
374
+ }
375
+
276
376
  async function handleDevices (from, p) {
277
377
  if (!isFresh(p.data)) return staleReply(from)
278
378
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
@@ -280,7 +380,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
280
380
  const { issued, revoked } = await identity.listDelegations()
281
381
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
282
382
  // política (quién manda, quién puede qué) sin un canal aparte.
283
- const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
383
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
284
384
  // Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
285
385
  // que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
286
386
  // de tragarse un salto a ciegas. Si se salió de la ventana, llega vacía y toca
@@ -301,7 +401,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
301
401
  const devices = await Promise.all(issued.map(async (x) => ({
302
402
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
303
403
  })))
304
- reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta, chain })
404
+ reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain })
305
405
  }
306
406
 
307
407
  // RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
@@ -322,10 +422,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
322
422
  // `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
323
423
  // QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
324
424
  // Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
325
- const acta = (await identity.profileActa?.().catch(() => null))?.acta
425
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
326
426
  let scope = p.cert.scope
327
- if (acta) {
328
- scope = Acta.memberScopes(acta, p.cert.sub)
427
+ if (record) {
428
+ scope = Acta.memberScopes(record, p.cert.sub)
329
429
  if (!scope.length) {
330
430
  audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
331
431
  return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
@@ -338,14 +438,53 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
338
438
  }
339
439
 
340
440
  // SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
341
- // pide el bundle de su namespace. La respuesta va SELLADA a la llave ECDH
441
+ // pide el bundle de su namespace el del SCOPE (que comparten todos los
442
+ // aparatos que sirven ese ns) con el SUYO PROPIO encima (`secretsStore.js`).
443
+ // Lo suyo se indexa por la llave que firma esta misma petición, así que no hay
444
+ // manera de pedir lo de otro aparato. La respuesta va SELLADA a la llave ECDH
342
445
  // efímera `ek` que vino en el sobre firmado (el proxy transporta pero no
343
446
  // puede leer los valores) y el cuerpo va FIRMADO por la maestra (el
344
447
  // servicio verifica contra su iss pineada — un relay no puede inyectar
345
448
  // secretos falsos). Replay inerte: cada petición usa una ek nueva.
346
449
  // data: { op:'secrets', ns, ek, publickey, ts }
450
+ /**
451
+ * Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
452
+ * suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
453
+ * de variables va indexado por ella — se quedaría sin configuración sin decirlo.
454
+ */
455
+ async function handleEncKey (from, p) {
456
+ const ns = p.data?.ns
457
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
458
+ const chk = await verifyChain({
459
+ data: p.data, signature: p.signature, cert: p.cert,
460
+ expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
461
+ })
462
+ if (!chk.ok) return denyChain(from, chk, p, 'enckey')
463
+ try {
464
+ await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
465
+ audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
466
+ log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
467
+ // Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
468
+ // seguiría sin poder abrir nada hasta la siguiente escritura.
469
+ await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
470
+ const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
471
+ const { signature } = await identity.signData(body)
472
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
473
+ } catch (e) {
474
+ reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
475
+ }
476
+ }
477
+
347
478
  async function handleSecrets (from, p) {
348
479
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
480
+ // REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
481
+ // un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
482
+ // de identidad y obligaría a re-vendorizar.
483
+ //
484
+ // No exige la contraseña del perfil, y el argumento importa: registrar una llave no
485
+ // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
486
+ // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
487
+ if (p.data?.op === 'enckey') return handleEncKey(from, p)
349
488
  const ns = p.data?.ns
350
489
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
351
490
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -357,14 +496,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
357
496
  // FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
358
497
  // miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
359
498
  // llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
360
- const acta = (await identity.profileActa?.().catch(() => null))?.acta
361
- if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
499
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
500
+ if (record && !Acta.memberCanReadSecrets(record, chk.device, ns)) {
362
501
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
363
502
  return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
364
503
  }
365
504
  let enc
366
505
  try {
367
- enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
506
+ // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
507
+ // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
508
+ // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
509
+ // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
510
+ const b = secrets.bundleFor(ns, chk.device)
511
+ // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
512
+ // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
513
+ // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
514
+ // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
515
+ // de dónde salió lo que abre.
516
+ const payload = b.legacy
517
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
518
+ : { sealed: b, acta: record || null }
519
+ enc = await seal({ ek: p.data.ek, payload })
368
520
  } catch (e) {
369
521
  return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
370
522
  }
@@ -383,9 +535,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
383
535
  if (payload.type === MSG.SIGN) return await handleSign(from, payload)
384
536
  if (payload.type === MSG.GET) return await handleGet(from, payload)
385
537
  if (payload.type === MSG.STORE) return await handleStore(from, payload)
538
+ if (payload.type === MSG.CHECK) return await handleCheck(from, payload)
386
539
  if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
387
540
  if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
388
541
  if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
542
+ if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
389
543
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
390
544
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
391
545
  } catch (e) {
@@ -408,14 +562,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
408
562
  // AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
409
563
  // escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
410
564
  // reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
411
- const AVISO_AGRUPA_MS = Number(process.env.DOTRINO_VAULT_AVISO_MS) || 3000
412
- const avisosPendientes = new Map() // ns → timer
565
+ // La variable de entorno va en inglés (CONVENCIONES §8.1); nadie la tenía puesta.
566
+ const NOTICE_GROUP_MS = Number(process.env.DOTRINO_VAULT_NOTICE_MS) || 3000
567
+ const pendingNotices = new Map() // clave (ns | 'dev:'+pub) → timer
413
568
 
414
- async function avisarCambio (ns) {
415
- let destinos = []
569
+ async function notifyNsChange (ns) {
570
+ let targets = []
416
571
  try {
417
572
  const { issued } = await identity.listDelegations()
418
- const revocados = await revocationSet()
573
+ const revokedNonces = await revocationSet()
419
574
  const scope = secretsScope(ns)
420
575
  // Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
421
576
  // que mandárselo a otro sería filtrarle que existe.
@@ -423,33 +578,62 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
423
578
  // Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
424
579
  // delegación nueva para la MISMA sub-clave, así que un agente que lleve
425
580
  // tiempo enrolado aparece varias veces y recibiría el aviso repetido.
426
- const vistas = new Set()
427
- destinos = (issued || []).filter((x) => {
428
- if (!x.sub || revocados.has(x.nonce) || !(x.scope || []).includes(scope)) return false
429
- if (vistas.has(x.sub)) return false
430
- vistas.add(x.sub)
581
+ const seen = new Set()
582
+ targets = (issued || []).filter((x) => {
583
+ if (!x.sub || revokedNonces.has(x.nonce) || !(x.scope || []).includes(scope)) return false
584
+ if (seen.has(x.sub)) return false
585
+ seen.add(x.sub)
431
586
  return true
432
587
  })
433
588
  } catch (e) { return log('[vault] could not list who to notify:', e.message) }
434
- if (!destinos.length) return
589
+ if (!targets.length) return
435
590
 
436
591
  const body = { op: 'secrets.changed', ns, ts: Date.now() }
437
592
  const { signature } = await identity.signData(body)
438
- for (const d of destinos) {
593
+ for (const d of targets) {
439
594
  try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
440
595
  }
441
- audit('secrets.changed', { ns, avisados: destinos.length })
442
- log(`[vault] config for "${ns}" changed: notified ${destinos.length} agent(s)`)
596
+ audit('secrets.changed', { ns, notified: targets.length })
597
+ log(`[vault] config for "${ns}" changed: notified ${targets.length} agent(s)`)
598
+ }
599
+
600
+ /**
601
+ * Cambió una variable de UN aparato: el aviso va solo a ese aparato. El mensaje dice
602
+ * qué NAMESPACE cambió (es lo que el agente sabe leer), así que hace falta su `cn`; un
603
+ * miembro sin `cn` no es un servicio y no lee variables, de modo que no hay a quién
604
+ * avisar y no se manda nada.
605
+ */
606
+ async function notifyDeviceChange (pub) {
607
+ let cn = null
608
+ try {
609
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
610
+ cn = (record?.members || []).find((m) => m.pub === pub)?.cn || null
611
+ } catch (e) { return log('[vault] could not look up who to notify:', e.message) }
612
+ if (!cn) return
613
+ const body = { op: 'secrets.changed', ns: cn, ts: Date.now() }
614
+ const { signature } = await identity.signData(body)
615
+ try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
616
+ const device = await deviceIdOf(pub).catch(() => null)
617
+ audit('secrets.changed', { ns: cn, device, notified: 1 })
618
+ log(`[vault] config for device ${device} ("${cn}") changed: notified it`)
443
619
  }
444
620
 
445
- function programarAviso (ns) {
446
- clearTimeout(avisosPendientes.get(ns))
621
+ /**
622
+ * Un cambio de configuración, un aviso: escrituras seguidas se agrupan en la misma
623
+ * ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
624
+ * lo de un aparato no cancele el aviso pendiente de todo su namespace.
625
+ */
626
+ function scheduleNotice (ns) { schedule(ns, () => notifyNsChange(ns)) }
627
+ function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
628
+
629
+ function schedule (key, fn) {
630
+ clearTimeout(pendingNotices.get(key))
447
631
  const t = setTimeout(() => {
448
- avisosPendientes.delete(ns)
449
- avisarCambio(ns).catch((e) => log('[vault] change notice failed:', e.message))
450
- }, AVISO_AGRUPA_MS)
632
+ pendingNotices.delete(key)
633
+ fn().catch((e) => log('[vault] change notice failed:', e.message))
634
+ }, NOTICE_GROUP_MS)
451
635
  t.unref?.()
452
- avisosPendientes.set(ns, t)
636
+ pendingNotices.set(key, t)
453
637
  }
454
638
 
455
639
  // --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
@@ -480,16 +664,110 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
480
664
  // UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
481
665
  // MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
482
666
  // y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
483
- // cuidado que en `avisarCambio`.
484
- const vistas = new Set()
667
+ // cuidado que en `notifyNsChange`.
668
+ const seen = new Set()
485
669
  for (const d of issued || []) {
486
- if (!d.sub || vistas.has(d.sub)) continue
487
- vistas.add(d.sub)
670
+ if (!d.sub || seen.has(d.sub)) continue
671
+ seen.add(d.sub)
488
672
  try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
489
673
  }
490
674
  } catch (e) { log('[vault] could not notify members of the change:', e.message) }
491
675
  }
492
676
 
677
+ /**
678
+ * MOSTRADOR DE VARIABLES para la consola remota (`lib/src/admin.js` enruta; la política
679
+ * y la cripto viven aquí, que es donde están la clave y el disco).
680
+ *
681
+ * Dos reglas, y son toda la frontera:
682
+ *
683
+ * 1. **El valor de una PRIVADA no sale de esta máquina.** Ni para un aparato tuyo con
684
+ * `admin`. Lo que viaja de una privada es su nombre y que es privada — con eso se
685
+ * le puede poner un valor nuevo a ciegas, que es lo que hace falta para rotarla.
686
+ * 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
687
+ * transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
688
+ */
689
+ /**
690
+ * La llave de administración a partir de lo que venga en el sobre de la consola
691
+ * remota. `undefined` si no trae contraseña — entonces el store cae a la de la
692
+ * máquina, que es lo correcto para un perfil que no tiene ninguna.
693
+ *
694
+ * La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
695
+ */
696
+ /**
697
+ * La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
698
+ * operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
699
+ */
700
+ async function adminKeyFrom (payload) {
701
+ const pwd = payload?.password
702
+ if (typeof pwd !== 'string' || !pwd) return undefined
703
+ if (typeof deriveAdminKey !== 'function') return undefined
704
+ return deriveAdminKey(pwd)
705
+ }
706
+
707
+ const varsDesk = {
708
+ async list () {
709
+ // `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
710
+ // la frontera se decide en un sitio, y lo mismo ve el dueño en su terminal que aquí.
711
+ // Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
712
+ // aprender cómo se llaman tus variables ni qué servicios corres.
713
+ return {
714
+ enc: await identity.sealContent(JSON.stringify({
715
+ ns: listSecrets(), dev: await listDeviceSecrets(),
716
+ // Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
717
+ // enseñarlo donde se administra, no solo en el log del servicio.
718
+ incomplete: await incompleteMembers(),
719
+ // Lo que quedó a deber un sellado: quien administra a distancia tiene que
720
+ // poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
721
+ // variable con la contraseña) y la bóveda no.
722
+ pending: await secretDebts(),
723
+ // Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
724
+ // llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
725
+ // El comentario de aquí decía «la consola lo dice en voz alta» y la consola
726
+ // no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
727
+ hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
728
+ }))
729
+ }
730
+ },
731
+ async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
732
+ const payload = JSON.parse(await identity.openContent(enc))
733
+ const value = payload?.value
734
+ if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
735
+ // NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
736
+ // leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
737
+ if (ns) await setSecret(ns, key, value, isPublic, { by: who })
738
+ else await setDeviceSecret(pub, key, value, isPublic, { by: who })
739
+ return { ok: true, key }
740
+ },
741
+ /**
742
+ * VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
743
+ * al servicio de que su configuración cambió, y el servicio SALE para releerla entera
744
+ * (`watchEnv`). Guardadas de una en una, quien administra a distancia reiniciaba el
745
+ * servicio una vez por variable, y las primeras veces arrancaba con la configuración a
746
+ * medio poner. Juntas: un guardado, un aviso, un reinicio.
747
+ *
748
+ * Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
749
+ * transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
750
+ */
751
+ async setMany ({ ns, pub, enc, public: isPublic, by: who = null }) {
752
+ const payload = JSON.parse(await identity.openContent(enc))
753
+ const items = payload?.items
754
+ if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
755
+ // Borrar no se delega (`docs/consola-remota.md` §2): un aparato robado no puede
756
+ // dejar sin configuración a un servicio. Así que aquí solo entran valores nuevos.
757
+ /** @type {Array<{op:'set', key:string, value:string, public?:boolean}>} */
758
+ const list = items.map((it) => ({
759
+ op: /** @type {'set'} */ ('set'),
760
+ key: it?.key,
761
+ value: it?.value,
762
+ ...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
763
+ }))
764
+ // NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` — sin él la
765
+ // escritura quedaba al aire y la respuesta salía antes de guardar nada.
766
+ const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
767
+ return { ok: true, keys }
768
+ }
769
+ }
770
+
493
771
  const admin = createAdminDesk({
494
772
  desk,
495
773
  deviceIdOf,
@@ -497,6 +775,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
497
775
  audit,
498
776
  notify: notifyMembers,
499
777
  readActivity,
778
+ vars: varsDesk,
500
779
  // CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
501
780
  // qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
502
781
  // hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
@@ -513,8 +792,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
513
792
  await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
514
793
  return chk
515
794
  }
516
- const acta = (await identity.profileActa?.().catch(() => null))?.acta
517
- if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
795
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
796
+ if (record && !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: 'acta' }
518
797
  return chk
519
798
  }
520
799
  })
@@ -556,29 +835,674 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
556
835
  reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
557
836
  }
558
837
 
559
- // API local de secretos (solo CLI/UI del dueño; audita cada cambio).
560
- function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }); programarAviso(ns) }
561
- function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); programarAviso(ns) } return ok }
562
- function listSecrets () { return secrets.list() }
838
+ // API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
839
+ // sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
840
+ /**
841
+ * La llave con la que se abre la copia maestra.
842
+ *
843
+ * Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
844
+ * contraseña se cae a la llave de la máquina**, que es exactamente la protección
845
+ * que había antes de todo esto: el disco sigue cifrado, pero su material vive en
846
+ * ese mismo disco, así que una copia del disco lo abre.
847
+ *
848
+ * Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
849
+ * pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
850
+ * diseño). Prometer una protección que no está puesta es peor que no tenerla.
851
+ */
852
+ const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
853
+
854
+ // `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
855
+ // usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
856
+ // listar y borrar no la piden (ver `secretsStore.js`).
857
+ /**
858
+ * Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
859
+ * (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
860
+ * CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
861
+ * que este diseño quita.
862
+ */
863
+ async function nsMembers (ns) {
864
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
865
+ return (record?.members || []).filter((m) => m.cn === ns)
866
+ }
867
+
868
+ /**
869
+ * Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
870
+ * cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
871
+ * ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
872
+ * frase que se escribe en cualquier parte a una llave que no sale del aparato.
873
+ *
874
+ * La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
875
+ * ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
876
+ * diseño quita. Hay un test que lo afirma.
877
+ */
878
+ async function adminDevices () {
879
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
880
+ return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
881
+ }
882
+
883
+ /**
884
+ * Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
885
+ * importa (decidida por el dueño el 2026-08-22):
886
+ *
887
+ * **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
888
+ * hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
889
+ * token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
890
+ * alguien se lleve tu portátil con la sesión puesta.
891
+ *
892
+ * Dos cosas que hacen que esto se sostenga:
893
+ *
894
+ * · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
895
+ * que la bóveda se negara a entregarlo, pero entonces la protección sería una
896
+ * política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
897
+ * llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
898
+ * se crea no se puede saltar.
899
+ * · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
900
+ * dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
901
+ * se puede forzar esperando a que ese servicio esté caído.
902
+ *
903
+ * Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
904
+ * miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
905
+ * abre la envoltura de recuperación, que `wrapAll` añade siempre.
906
+ */
907
+ /**
908
+ * Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
909
+ * el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
910
+ * quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
911
+ * qué pidió; aquí solo se valida el remitente.
912
+ */
913
+ async function handleRewrapOk (payload) {
914
+ const d = payload?.data
915
+ if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
916
+ const signer = payload?.cert?.sub
917
+ if (!signer) return
918
+ if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
919
+ return log('[vault] a handed key arrived BADLY SIGNED: ignored')
920
+ }
921
+ // Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
922
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
923
+ const m = (record?.members || []).find((x) => x.pub === signer)
924
+ const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
925
+ if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
926
+ for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
927
+ }
928
+
929
+ /** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
930
+ const rewrapWaiters = new Set()
931
+ const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
932
+
933
+ async function recipientsOf (owner) {
934
+ if (owner.startsWith('ns:')) {
935
+ const owned = await nsMembers(owner.slice(3))
936
+ // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
937
+ // administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
938
+ return owned.length ? owned : await adminDevices()
939
+ }
940
+ const pub = owner.slice(owner.indexOf(':') + 1)
941
+ const m = await memberOf(pub)
942
+ // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
943
+ if (m?.cn) return [m]
944
+ return [...(m ? [m] : []), ...await adminDevices()]
945
+ }
946
+
947
+ /**
948
+ * Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
949
+ * del acta junto a la firma: es lo que le dice a quien verifica con qué llave
950
+ * comprobarla, porque esa llave rota con el acta (§8.9).
951
+ *
952
+ * Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
953
+ * es más importante que poder demostrar después de dónde salió.
954
+ */
955
+ async function signSeal (body) {
956
+ try {
957
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
958
+ if (!acta?.sealPub) return null
959
+ const sig = await sealKeys.sign(acta.sealPub, body)
960
+ return sig ? { seq: acta.seq, sig } : null
961
+ } catch (_) { return null }
962
+ }
963
+
964
+ /**
965
+ * Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
966
+ * del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
967
+ * abrir y se queda reintentando para siempre, sin decir por qué.
968
+ *
969
+ * No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
970
+ * reiniciaría a todos los nodos del ns para nada.
971
+ */
972
+ /**
973
+ * Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
974
+ * el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
975
+ * consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
976
+ *
977
+ * Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
978
+ * que ya no está podría abrir lo que se escriba mañana.
979
+ */
980
+ async function markRotationDue (sub) {
981
+ const m = await memberOf(sub)
982
+ const ns = m?.cn
983
+ if (!ns) return
984
+ try {
985
+ const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
986
+ if (r?.rotated != null) {
987
+ log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
988
+ audit('secret.rotate', { ns, keys: r.rotated })
989
+ scheduleNotice(ns)
990
+ return
991
+ }
992
+ } catch (e) {
993
+ store.setSetting(`rotate-due:${ns}`, String(Date.now()))
994
+ log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
995
+ audit('secret.rotate-due', { ns, reason: e.message })
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
1001
+ * deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
1002
+ * salen juntas:
1003
+ *
1004
+ * - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
1005
+ * - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
1006
+ *
1007
+ * Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
1008
+ * enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
1009
+ * que además nadie ve.
1010
+ */
1011
+ /**
1012
+ * Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
1013
+ * las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
1014
+ * llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
1015
+ * hay quien la teclee.
1016
+ *
1017
+ * Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
1018
+ * vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
1019
+ * otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
1020
+ * mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
1021
+ * (`dotrino-vault secret settle`, que sí pide la frase).
1022
+ *
1023
+ * @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
1024
+ */
1025
+ async function incompleteMembers () {
1026
+ if (secrets.isLegacy?.()) return []
1027
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1028
+ const out = []
1029
+ for (const m of record?.members || []) {
1030
+ if (!m.encPub || m.pub === master) continue
1031
+ const owners = {}
1032
+ // Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
1033
+ // puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
1034
+ // «Ver» de la consola le fallaría sin explicar por qué.
1035
+ // Un aparato que administra NO debe tener envoltura de los cajones con dueño
1036
+ // (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
1037
+ // propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
1038
+ const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
1039
+ const drawers = m.cn
1040
+ ? [`ns:${m.cn}`, `dev:${m.pub}`]
1041
+ : [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
1042
+ for (const owner of drawers) {
1043
+ const missing = secrets.missingFor(owner, m.pub)
1044
+ if (missing.length) owners[owner] = missing
1045
+ }
1046
+ if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
1047
+ }
1048
+ return out
1049
+ }
1050
+
1051
+ /**
1052
+ * Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
1053
+ * no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
1054
+ * estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
1055
+ */
1056
+ function rotationsDue () {
1057
+ const out = {}
1058
+ for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
1059
+ // La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
1060
+ // nombre del namespace: una sola forma para quien lo lee.
1061
+ if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
1062
+ }
1063
+ return out
1064
+ }
1065
+
1066
+ /**
1067
+ * TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
1068
+ * envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
1069
+ * se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
1070
+ * se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
1071
+ * entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
1072
+ * @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
1073
+ */
1074
+ async function secretDebts () {
1075
+ const out = rotationsDue()
1076
+ for (const m of await incompleteMembers()) {
1077
+ for (const [owner, keys] of Object.entries(m.owners)) {
1078
+ if (out[owner]?.kind === 'rotate') continue
1079
+ out[owner] = out[owner] || { kind: 'rewrap', members: [] }
1080
+ out[owner].members.push({ pub: m.pub, keys })
1081
+ }
1082
+ }
1083
+ return out
1084
+ }
1085
+
1086
+ async function spreadKey (owner, members, adminKey) {
1087
+ if (secrets.isLegacy()) return null
1088
+ // Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
1089
+ // se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
1090
+ // sustituye al reparto en vez de sumarse.
1091
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1092
+ if (ns && store.getSetting(`rotate-due:${ns}`)) {
1093
+ const rot = await secrets.rotate(owner, members, adminKey)
1094
+ store.setSetting(`rotate-due:${ns}`, undefined)
1095
+ log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
1096
+ audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
1097
+ return rot
1098
+ }
1099
+ try {
1100
+ const r = await secrets.rewrap(owner, members, adminKey)
1101
+ if (r?.sinLlave?.length) {
1102
+ log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
1103
+ audit('secret.nokey', { owner, count: r.sinLlave.length })
1104
+ }
1105
+ return r
1106
+ } catch (e) {
1107
+ // SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
1108
+ // donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
1109
+ // cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
1110
+ // del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
1111
+ // — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
1112
+ // anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
1113
+ // salde un hermano o el abrir la bóveda.
1114
+ log(`[vault] ${owner}: could not hand out its key (${e.message}); its members will not read their variables until a sibling hands it out or the vault is opened`)
1115
+ audit('secret.rewrap-due', { owner, reason: e.message })
1116
+ throw e
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
1122
+ * a leer. `by` es el aparato que lo escribió, y va al histórico.
1123
+ */
1124
+ async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
1125
+ await secrets.set(ns, key, value, isPublic, { by })
1126
+ await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1127
+ audit('secret.set', { ns, key }); scheduleNotice(ns)
1128
+ }
1129
+
1130
+ /**
1131
+ * SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
1132
+ * desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
1133
+ * verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
1134
+ * único que la frase guarda (§8.3).
1135
+ *
1136
+ * No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
1137
+ * tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
1138
+ */
1139
+ /**
1140
+ * PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
1141
+ *
1142
+ * Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
1143
+ * llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
1144
+ * tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
1145
+ *
1146
+ * La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
1147
+ * podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
1148
+ * destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
1149
+ * solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
1150
+ *
1151
+ * @returns {Promise<{ done: number, asked: number }>}
1152
+ */
1153
+ async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
1154
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1155
+ if (!ns) return { done: 0, asked: 0 }
1156
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1157
+ if (!record) return { done: 0, asked: 0 }
1158
+ // Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
1159
+ const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
1160
+ const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
1161
+ if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
1162
+
1163
+ let done = 0
1164
+ for (const gen of pending) {
1165
+ const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
1166
+ const { signature } = await identity.signData(body)
1167
+ const answer = new Promise((resolve) => {
1168
+ const off = onRewrapOk((d) => {
1169
+ if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
1170
+ off(); resolve(d.wrap)
1171
+ })
1172
+ setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
1173
+ })
1174
+ try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, signature }) } catch (e) {
1175
+ log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
1176
+ continue
1177
+ }
1178
+ const wrap = await answer
1179
+ if (!wrap) continue
1180
+ try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
1181
+ log(`[vault] ${owner}: the handed key was refused (${e.message})`)
1182
+ }
1183
+ }
1184
+ if (done) {
1185
+ audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
1186
+ log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
1187
+ }
1188
+ return { done, asked: pending.length }
1189
+ }
1190
+
1191
+ async function resealAll (adminKey = null) {
1192
+ const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
1193
+ for (const owner of secrets.owners?.() || []) {
1194
+ const before = new Set(secrets.recipientsIn(owner))
1195
+ const members = await recipientsOf(owner)
1196
+ try {
1197
+ const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
1198
+ const after = new Set(secrets.recipientsIn(owner))
1199
+ out.drawers++
1200
+ out.wrapped += r?.wrapped || 0
1201
+ out.dropped += [...before].filter((p) => !after.has(p)).length
1202
+ for (const p of r?.sinLlave || []) out.sinLlave.push(p)
1203
+ } catch (e) {
1204
+ log(`[vault] ${owner}: could not reseal (${e.message})`)
1205
+ }
1206
+ }
1207
+ if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
1208
+ return out
1209
+ }
1210
+
1211
+ async function settleSecretDebts (adminKey = null) {
1212
+ const out = {}
1213
+ // Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
1214
+ // envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
1215
+ // por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
1216
+ // pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
1217
+ // que es barato y no depende de que alguien se acordara de apuntarlo.
1218
+ const debts = new Set(Object.keys(rotationsDue()))
1219
+ for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
1220
+
1221
+ for (const owner of debts) {
1222
+ const k = owner.slice(owner.indexOf(':') + 1)
1223
+ // Los destinatarios de un cajón no son solo quien lo consume: también quien lo
1224
+ // administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
1225
+ // sin poder destapar lo que ella misma acababa de saldar.
1226
+ const members = await recipientsOf(owner)
1227
+ try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
1228
+ // Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
1229
+ let delegated = 0
1230
+ for (const m of await incompleteMembers()) {
1231
+ if (!m.owners[owner]) continue
1232
+ delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
1233
+ }
1234
+ out[owner] = delegated ? { delegated } : { error: e.message }
1235
+ }
1236
+ }
1237
+ return out
1238
+ }
1239
+
1240
+ /**
1241
+ * Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
1242
+ * Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
1243
+ * recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
1244
+ * anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
1245
+ * guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
1246
+ */
1247
+ async function settleDebts (owner, membersFn) {
1248
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1249
+ const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
1250
+ (await incompleteMembers()).some((m) => m.owners[owner])
1251
+ if (!owed) return null
1252
+ try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
1253
+ }
1254
+ async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
1255
+
1256
+ /**
1257
+ * CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
1258
+ *
1259
+ * De una en una, cada `set` es un cambio de configuración para la bóveda, y el agente
1260
+ * obedece el primero —sale, lo levanta el supervisor, lee lo que hubiera en ese
1261
+ * instante— mientras el dueño sigue tecleando. El resultado es un servicio corriendo
1262
+ * con media configuración, y encima con el arranque a medio hacer. La ventana de
1263
+ * agrupado (`NOTICE_GROUP_MS`) tapa el caso de un script, no el de una persona
1264
+ * escribiendo con quince segundos entre variable y variable.
1265
+ *
1266
+ * Así que la carga en grupo llega hasta aquí entera: se valida TODO primero, se
1267
+ * escribe en un solo guardado y sale UN aviso al final. Las visibilidades no entran:
1268
+ * no cambian lo que el servicio lee y por eso nunca avisaron.
1269
+ *
1270
+ * @param {string} ns
1271
+ * @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
1272
+ * @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
1273
+ * estaba no cambia nada, y no tiene por qué reiniciar a nadie).
1274
+ */
1275
+ async function applySecrets (ns, items, { by = null } = {}) {
1276
+ const list = assertItems(items)
1277
+ const changed = []
1278
+ await secrets.batch(async () => {
1279
+ for (const it of list) {
1280
+ if (it.op === 'rm') {
1281
+ if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
1282
+ } else {
1283
+ await secrets.set(ns, it.key, it.value, it.public, { by })
1284
+ audit('secret.set', { ns, key: it.key })
1285
+ changed.push(it.key)
1286
+ }
1287
+ }
1288
+ })
1289
+ if (changed.length) {
1290
+ await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1291
+ scheduleNotice(ns)
1292
+ }
1293
+ return changed
1294
+ }
1295
+
1296
+ /** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
1297
+ async function applyDeviceSecrets (pub, items, { by = null } = {}) {
1298
+ const list = assertItems(items)
1299
+ const m = await requireService(pub)
1300
+ const changed = []
1301
+ await secrets.batch(async () => {
1302
+ for (const it of list) {
1303
+ if (it.op === 'rm') {
1304
+ if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
1305
+ } else {
1306
+ await secrets.setDevice(pub, it.key, it.value, it.public, { by })
1307
+ changed.push(it.key)
1308
+ }
1309
+ }
1310
+ })
1311
+ if (changed.length) {
1312
+ await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
1313
+ const device = await deviceIdOf(pub).catch(() => null)
1314
+ for (const it of list) {
1315
+ if (!changed.includes(it.key)) continue
1316
+ audit(it.op === 'rm' ? 'secret.rm' : 'secret.set', { device, ns: m?.cn || null, key: it.key, scope: 'device' })
1317
+ }
1318
+ scheduleDeviceNotice(pub)
1319
+ }
1320
+ return changed
1321
+ }
1322
+
1323
+ /**
1324
+ * Todo o nada: si una variable del grupo no vale, no se escribe NINGUNA. Media
1325
+ * configuración cargada es peor que ninguna — el servicio arranca con ella.
1326
+ */
1327
+ function assertItems (items) {
1328
+ if (!Array.isArray(items) || !items.length) throw new Error('batch: no items')
1329
+ for (const it of items) {
1330
+ if (!it || (it.op !== 'set' && it.op !== 'rm')) throw new Error('batch: each item must be a set or an rm')
1331
+ if (it.op === 'rm') { if (!it.key) throw new Error('batch: rm needs a key') } else assertVar(it.key, it.value)
1332
+ }
1333
+ return items
1334
+ }
1335
+ /**
1336
+ * Los nombres, y el VALOR de las públicas. Pública quiere decir «este valor puede salir
1337
+ * de esta máquina»: taparlo justo aquí —en la máquina donde vive, delante de su dueño—
1338
+ * era lo único que la marca no significaba. La consola remota ya las enseña.
1339
+ */
1340
+ function listSecrets () {
1341
+ const out = {}
1342
+ for (const [ns, keys] of Object.entries(secrets.list())) {
1343
+ const values = secrets.publicOf(ns)
1344
+ out[ns] = keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k))
1345
+ }
1346
+ return out
1347
+ }
1348
+ /** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
1349
+ async function setSecretVisibility (ns, key, isPublic, adminKey) {
1350
+ const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
1351
+ if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
1352
+ return ok
1353
+ }
1354
+
1355
+ /**
1356
+ * El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
1357
+ * camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
1358
+ * este sí pide poder abrir la copia maestra.
1359
+ */
1360
+ async function openSecrets (ns, devicePub = null, adminKey) {
1361
+ return secrets.openBundle(ns, devicePub, adminKey)
1362
+ }
1363
+
1364
+ /** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
1365
+ async function memberOf (pub) {
1366
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1367
+ if (!record) return null
1368
+ return (record.members || []).find((m) => m.pub === pub) || null
1369
+ }
1370
+
1371
+ /**
1372
+ * Variables de UN aparato. Se exige que sea un SERVICIO del acta (miembro con `cn`) porque
1373
+ * es el único que las lee: guardárselas a un teléfono sería configuración muerta, escrita
1374
+ * donde nadie la va a buscar el día que no funcione. Si la bóveda es anterior al acta no
1375
+ * hay contra qué comprobarlo y se acepta.
1376
+ */
1377
+ async function requireService (pub) {
1378
+ const m = await memberOf(pub)
1379
+ if (!m) {
1380
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1381
+ if (record) throw new Error('device: it is not a member of this profile')
1382
+ return null
1383
+ }
1384
+ if (!m.cn) throw new Error('device: it is not a service (only services read variables); pair it with `pair --service <ns>`')
1385
+ return m
1386
+ }
1387
+
1388
+ async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
1389
+ const m = await requireService(pub)
1390
+ await secrets.setDevice(pub, key, value, isPublic, { by })
1391
+ await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
1392
+ audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
1393
+ scheduleDeviceNotice(pub)
1394
+ }
1395
+
1396
+ async function deleteDeviceSecret (pub, key) {
1397
+ const m = await memberOf(pub)
1398
+ const ok = await secrets.deleteDevice(pub, key)
1399
+ if (ok) {
1400
+ audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
1401
+ scheduleDeviceNotice(pub)
1402
+ }
1403
+ return ok
1404
+ }
1405
+
1406
+ async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
1407
+ const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
1408
+ if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
1409
+ return ok
1410
+ }
1411
+
1412
+ /**
1413
+ * Las variables por aparato —nombres, y el valor de las PÚBLICAS, igual que `listSecrets`—
1414
+ * con quién es cada aparato pegado: una llave suelta no se puede administrar. `orphan`
1415
+ * marca las que quedaron de una llave que ya no está en el acta.
1416
+ */
1417
+ async function listDeviceSecrets () {
1418
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1419
+ const members = record?.members || []
1420
+ const out = []
1421
+ for (const [pub, keys] of Object.entries(secrets.listDevices())) {
1422
+ const m = members.find((x) => x.pub === pub) || null
1423
+ const values = secrets.publicOfDevice(pub)
1424
+ out.push({
1425
+ pub,
1426
+ id: m?.id || await deviceIdOf(pub).catch(() => null),
1427
+ label: m?.label || '',
1428
+ cn: m?.cn || null,
1429
+ keys: keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k)),
1430
+ orphan: !!members.length && !m
1431
+ })
1432
+ }
1433
+ return out
1434
+ }
563
1435
 
564
1436
  return {
565
- identity, client, store, threads, secrets, master, fingerprint: fp,
1437
+ identity, client, store, threads, secrets, master, fingerprint: fp, dir,
566
1438
  startPairing: desk.startPairing,
567
1439
  stopPairing: desk.stopPairing,
568
1440
  listPending: desk.listPending,
569
1441
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
570
1442
  // dispositivos se entera de que entró alguien, venga de donde venga.
571
- approveDevice: async (code) => {
1443
+ approveDevice: async (code, adminKey) => {
572
1444
  const r = await desk.approve(code)
1445
+ // Un servicio que ENTRA a un namespace que ya tiene variables necesita su
1446
+ // envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
1447
+ // reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
1448
+ // puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
1449
+ // el archivo vendorizado en el iframe de identidad.
1450
+ const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
1451
+ if (m?.cn) {
1452
+ await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
1453
+ log('[vault] could not hand the key to the new service:', e.message)
1454
+ // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
1455
+ // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
1456
+ // recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
1457
+ // a la vista y se salda al abrir la bóveda.
1458
+ const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
1459
+ if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
1460
+ })
1461
+ }
573
1462
  await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
574
1463
  return r
575
1464
  },
576
1465
  rejectDevice: (deviceId) => desk.reject(deviceId),
577
- setSecret, deleteSecret, listSecrets,
1466
+ // El mostrador que atiende a la consola remota. Se expone para poder probar la
1467
+ // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
1468
+ vars: varsDesk,
1469
+ setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
1470
+ // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
1471
+ // por eso es una operación con nombre propio, no algo que ocurra de refilón al
1472
+ // desbloquear: deja `secrets.json.v3.bak` para poder volver.
1473
+ /**
1474
+ * Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
1475
+ * normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
1476
+ * cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
1477
+ *
1478
+ * Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
1479
+ * entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
1480
+ * diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
1481
+ */
1482
+ resealAll,
1483
+ delegateRewrap,
1484
+ incompleteMembers,
1485
+ migrateSecrets: (membersOf, adminKey) => secrets.migrate(
1486
+ membersOf || ((owner) => recipientsOf(owner)), adminKey
1487
+ ),
1488
+ settleSecretDebts,
1489
+ revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
1490
+ secretHistory: (owner, key) => secrets.history(owner, key),
1491
+ revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
1492
+ revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
1493
+ // Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
1494
+ // la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
1495
+ rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
1496
+ setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
1497
+ applySecrets, applyDeviceSecrets,
578
1498
  listDevices: () => identity.listDelegations(),
579
1499
  // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
580
1500
  // `dotrino-vault members` y la consola de vault.dotrino.com.
581
1501
  profileMembers: () => identity.profileMembers(),
1502
+ // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
1503
+ // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
1504
+ rotationsDue,
1505
+ secretDebts,
582
1506
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
583
1507
  isMaster: () => identity.isMaster(),
584
1508
  setCaps: async (pub, caps) => {
@@ -613,8 +1537,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
613
1537
  return r
614
1538
  },
615
1539
  close () {
616
- for (const t of avisosPendientes.values()) clearTimeout(t)
617
- avisosPendientes.clear()
1540
+ for (const t of pendingNotices.values()) clearTimeout(t)
1541
+ pendingNotices.clear()
618
1542
  try { client.close() } catch (_) {} identity.destroy()
619
1543
  }
620
1544
  }