@dotrino/vaultd 0.92.0 → 0.93.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
package/src/ctl.js CHANGED
@@ -573,7 +573,7 @@ async function cmdMembers () {
573
573
  m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
574
574
  marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
575
575
  // Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
576
- // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
576
+ // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio where_
577
577
  // se mira quién es quién — y en la lista de variables ya seria tarde.
578
578
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
579
579
  }
@@ -780,7 +780,7 @@ function profileDir () {
780
780
  * antes de tener clave, `key.json` es una pública que dice de quién es la carpeta, y las
781
781
  * preferencias no son de nadie. Todo lo demás en claro es un hallazgo.
782
782
  */
783
- function enClaroEn (d) {
783
+ function plaintextFilesIn (d) {
784
784
  const OK = new Set(['atrest.salt', 'atrest.machine', 'atrest.kek', 'atrest.json', 'vault.lock', 'prefs.json', 'key.json'])
785
785
  let names = []
786
786
  try { names = fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch (_) { return [] }
@@ -820,11 +820,11 @@ function cmdAtrest (rest) {
820
820
  // versión vieja dejó en claro, con el verificador de la contraseña dentro— meses
821
821
  // después de que su migración terminara. Se mira el perfil Y la raíz, porque el canal
822
822
  // local con la CLI vive arriba.
823
- for (const [donde, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
824
- const sueltos = enClaroEn(d)
825
- if (sueltos.length) {
823
+ for (const [where_, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
824
+ const loose = plaintextFilesIn(d)
825
+ if (loose.length) {
826
826
  console.log('')
827
- console.log(`${R}En claro en ${donde}:${Z} ` + sueltos.join(', '))
827
+ console.log(`${R}En claro en ${where_}:${Z} ` + loose.join(', '))
828
828
  console.log(' Si es un respaldo de una migración terminada, bórralo: no lo cifra nadie.')
829
829
  }
830
830
  }
@@ -958,7 +958,7 @@ function cmdActivity (n = 30) {
958
958
  /**
959
959
  * `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
960
960
  * son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
961
- * donde el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
961
+ * where_ el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
962
962
  */
963
963
  function asPairs (args) {
964
964
  if (!args.length) return null
@@ -1395,7 +1395,7 @@ function reportProfiles (d) {
1395
1395
  async function cmdProfile (rest) {
1396
1396
  const [sub, ...rawArgs] = rest
1397
1397
  // `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
1398
- // los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
1398
+ // los argumentos loose, así que si no se quita acabaría llamándose «midevault --kms
1399
1399
  // cfg.json».
1400
1400
  const kmsAt = rawArgs.indexOf('--kms')
1401
1401
  const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
package/src/daemon.js CHANGED
@@ -20,7 +20,7 @@ import { takeLock } from '../lib/src/lock.js'
20
20
  import { startVaultManager } from './manager.js'
21
21
  import { dataDir } from './paths.js'
22
22
  // EL CANAL LOCAL VA CIFRADO: por aquí pasan la contraseña, los valores y las invitaciones.
23
- import { ipcRead, ipcWrite } from './ipc.js'
23
+ import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
24
24
  import { parseInvite } from '../lib/src/invite.js'
25
25
  import { watchBinary } from './selfupdate.js'
26
26
  import { VERSION } from './version.js'
@@ -70,6 +70,9 @@ export async function runDaemon () {
70
70
  const dir = dataDir()
71
71
  const proxyUrl = process.env.PROXY_URL || 'wss://proxy.dotrino.com'
72
72
  assertSingleInstance(dir)
73
+ // Lo que dejó escrito una versión anterior al canal cifrado. Se agota sola.
74
+ const sealedLeftovers = migrateIpcDir(dir)
75
+ if (sealedLeftovers) console.log('[vault] control channel: %d leftover file(s) sealed at rest', sealedLeftovers)
73
76
 
74
77
  const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
75
78
  // Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
package/src/ipc.js CHANGED
@@ -26,6 +26,7 @@
26
26
  * Ojo con el dir: todos estos archivos cuelgan del directorio raíz, así que la clave sale
27
27
  * de ahí (`path.dirname`). Un archivo de perfil tiene otro salt y NO se lee con esta.
28
28
  */
29
+ import fs from 'node:fs'
29
30
  import path from 'node:path'
30
31
  import { atRestFor } from './atrest.js'
31
32
  import { readJson, writeJson } from './paths.js'
@@ -38,4 +39,33 @@ export const ipcRead = (file, fallback = null) => readJson(file, fallback, codec
38
39
  /** Escribe un archivo del canal, cifrado y 0600 (atómico: tmp + rename). */
39
40
  export const ipcWrite = (file, obj) => writeJson(file, obj, codecOf(file))
40
41
 
41
- export default { ipcRead, ipcWrite }
42
+ /**
43
+ * CONVIERTE LO QUE DEJÓ ESCRITO UNA VERSIÓN ANTERIOR.
44
+ *
45
+ * Cifrar al escribir no limpia el disco: un archivo del canal solo se reescribe cuando
46
+ * alguien vuelve a pedir esa operación, y hay respuestas que se quedan meses. `approve.json`
47
+ * —con el id del último aparato aprobado— seguía en claro días después de la actualización,
48
+ * esperando a que hubiera otra aprobación.
49
+ *
50
+ * Se pasa una vez al arrancar el daemon y se agota sola. Un archivo que no se pueda leer se
51
+ * deja como está: no es de nadie borrar lo que no entiende.
52
+ */
53
+ export function migrateIpcDir (dir) {
54
+ let names = []
55
+ try { names = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch (_) { return 0 }
56
+ let n = 0
57
+ for (const name of names) {
58
+ if (!name.endsWith('.json') || name.startsWith('atrest.')) continue
59
+ const file = path.join(dir, name)
60
+ try {
61
+ const raw = fs.readFileSync(file, 'utf8')
62
+ if (!raw.trim() || raw.startsWith('DOTRINO-ATREST-v1.')) continue
63
+ const obj = JSON.parse(raw)
64
+ ipcWrite(file, obj)
65
+ n++
66
+ } catch (_) { /* no se entiende: se deja */ }
67
+ }
68
+ return n
69
+ }
70
+
71
+ export default { ipcRead, ipcWrite, migrateIpcDir }
@@ -23,11 +23,23 @@ import { atRestFor } from './atrest.js'
23
23
  export function fileLocalStorage (filePath) {
24
24
  const atRest = atRestFor(path.dirname(filePath))
25
25
  let data = {}
26
- try { if (fs.existsSync(filePath)) data = JSON.parse(atRest.decrypt(fs.readFileSync(filePath, 'utf8'))) || {} } catch (_) {}
26
+ let wasPlaintext = false
27
+ try {
28
+ if (fs.existsSync(filePath)) {
29
+ const raw = fs.readFileSync(filePath, 'utf8')
30
+ wasPlaintext = !!raw.trim() && !raw.startsWith('DOTRINO-ATREST-v1.')
31
+ data = JSON.parse(atRest.decrypt(raw)) || {}
32
+ }
33
+ } catch (_) {}
27
34
  const flush = () => {
28
35
  fs.mkdirSync(path.dirname(filePath), { recursive: true })
29
36
  fs.writeFileSync(filePath, atRest.encrypt(JSON.stringify(data)), { mode: 0o600 })
30
37
  }
38
+ // CIFRAR AL ESCRIBIR NO CONVIERTE LO YA ESCRITO, y aquí eso importaba de verdad: el par
39
+ // de transporte se acuña UNA vez y no se vuelve a tocar, así que el archivo que dejó una
40
+ // versión anterior —con su privada en claro— se quedaba así para siempre. Se convierte de
41
+ // una vez al abrirlo. Se agota solo: la siguiente vez ya está cifrado.
42
+ if (wasPlaintext) { try { flush() } catch (_) {} }
31
43
  return {
32
44
  getItem: (k) => (k in data ? data[k] : null),
33
45
  setItem: (k, v) => { data[k] = String(v); flush() },
package/src/vault.js CHANGED
@@ -119,7 +119,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
119
119
  * Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
120
120
  * no, espera a que un aparato con `approve` lo firme.
121
121
  *
122
- * Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
122
+ * Antes era al revés y vivía aquí: una list_ local de «estos SÍ piden permiso», así que
123
123
  * un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
124
124
  * omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
125
125
  * el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
@@ -135,7 +135,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
135
135
  }
136
136
  // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
137
137
  // reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
138
- // `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
138
+ // `passwords`), como cualquier otro permiso: aquí no hay una segunda list_ de
139
139
  // aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
140
140
  // además un emparejamiento paralelo al del ecosistema.
141
141
  const passwordsFile = path.join(dir, 'passwords.json')
@@ -260,25 +260,41 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
260
260
  * `identify` se repliega a la maestra y lo dice. Por eso una bóveda que se actualiza
261
261
  * tiene que abrirse UNA vez, y a partir de ahí ya puede vivir cerrada.
262
262
  */
263
- try {
264
- const info = await identity.profileActa?.()
265
- const acta = info?.acta
266
- // CON EL PERFIL ABIERTO, y `isLocked()` es la pregunta correcta — no `masterLocked`.
267
- //
268
- // Se coló así: en la PRIMERA arrancada tras actualizar, la maestra todavía está guardada
269
- // en claro (se sella al abrir el perfil), o sea que `masterLocked` es `false` aunque el
270
- // perfil esté cerrado. Con ese guardián, la bóveda de producción admitió su llave de
271
- // comunicación y SELLÓ UN ACTA NUEVA (#76 → #77) estando cerrada — justo lo que la regla
272
- // prohíbe, y encima rotando de paso la llave de sellado sin que nadie lo pidiera.
273
- if (acta && !isLocked() && !identity.masterLocked) {
263
+ /**
264
+ * METER LA LLAVE DE COMUNICACIÓN EN EL ACTA, si el perfil está abierto.
265
+ *
266
+ * `isLocked()` es la pregunta correcta — no `masterLocked`. Se coló así: en la PRIMERA
267
+ * arrancada tras actualizar, la maestra todavía está guardada en claro (se sella al abrir
268
+ * el perfil), o sea que `masterLocked` es `false` aunque el perfil esté cerrado. Con ese
269
+ * guardián, la bóveda de producción admitió su llave de comunicación y SELLÓ UN ACTA
270
+ * NUEVA (#76 → #77) estando cerrada — justo lo que la regla prohíbe, y encima rotando de
271
+ * paso la llave de sellado sin que nadie lo pidiera.
272
+ *
273
+ * SE LLAMA TAMBIÉN AL ABRIR, y esa es la mitad que faltaba. Antes solo corría al arrancar
274
+ * el servicio, y **un perfil con contraseña arranca cerrado siempre**: nunca pasaba por
275
+ * aquí, así que jamás llegaba a tener su llave. O sea que los perfiles que MÁS la
276
+ * necesitan —los protegidos, los que no pueden firmar con la maestra estando cerrados—
277
+ * eran precisamente los únicos que se quedaban sin ella, y seguían identificándose con la
278
+ * maestra o quedándose mudos. Se vio el 2026-09-02: de dos perfiles, el que tenía
279
+ * contraseña era el que no tenía `commkey.json`.
280
+ */
281
+ async function ensureCommKeyInActa () {
282
+ try {
283
+ const info = await identity.profileActa?.()
284
+ const acta = info?.acta
285
+ if (!acta || isLocked() || identity.masterLocked) return false
274
286
  const pub = await commKey.ensure()
275
- const yaEsta = (acta.members || []).some((m) => m?.pub === pub)
276
- if (!yaEsta && info?.isMaster) {
277
- await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
278
- log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
279
- }
287
+ if ((acta.members || []).some((m) => m?.pub === pub)) return false
288
+ if (!info?.isMaster) return false
289
+ await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
290
+ log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
291
+ return true
292
+ } catch (e) {
293
+ log('[vault] could not put the communication key in the record:', e.message)
294
+ return false
280
295
  }
281
- } catch (e) { log('[vault] could not put the communication key in the record:', e.message) }
296
+ }
297
+ await ensureCommKeyInActa()
282
298
 
283
299
  const { client } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
284
300
 
@@ -338,15 +354,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
338
354
  // cierto también para lo que ya estaba escrito, no solo para lo que se escriba desde
339
355
  // ahora. Se agota sola — cuando no queda ninguna línea en claro, no vuelve a tocar nada.
340
356
  try {
341
- const crudo = fs.readFileSync(activityFile, 'utf8')
342
- const lineas = crudo.split('\n')
343
- if (lineas.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
344
- const convertidas = lineas.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
357
+ const raw = fs.readFileSync(activityFile, 'utf8')
358
+ const lines = raw.split('\n')
359
+ if (lines.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
360
+ const sealedLines = lines.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
345
361
  // Escribir aparte y renombrar: a medio convertir, la bitácora sería ilegible entera.
346
- fs.writeFileSync(activityFile + '.tmp', convertidas.join('\n'), { mode: 0o600 })
362
+ fs.writeFileSync(activityFile + '.tmp', sealedLines.join('\n'), { mode: 0o600 })
347
363
  fs.renameSync(activityFile + '.tmp', activityFile)
348
364
  // Sin `%d`: el `log` de la bóveda no formatea, así que el placeholder salía literal.
349
- log(`[vault] activity log: ${convertidas.filter(Boolean).length} line(s) sealed at rest`)
365
+ log(`[vault] activity log: ${sealedLines.filter(Boolean).length} line(s) sealed at rest`)
350
366
  }
351
367
  // Y el modo, que hasta 0.89 se creaba 0664: legible por cualquier usuario.
352
368
  if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600)
@@ -448,7 +464,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
448
464
  *
449
465
  * Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
450
466
  * multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
451
- * los rechazaban los diez mostradores. Ahora se compara contra la lista que dice el acta.
467
+ * los rechazaban los diez mostradores. Ahora se compara contra la list_ que dice el acta.
452
468
  *
453
469
  * Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
454
470
  * mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
@@ -694,7 +710,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
694
710
  audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
695
711
  return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, acta: record })
696
712
  }
697
- // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
713
+ // El acta viaja con la list_: así cada dispositivo se entera de los cambios de
698
714
  // política (quién manda, quién puede qué) sin un canal aparte.
699
715
  // Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
700
716
  // que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
@@ -712,7 +728,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
712
728
  }
713
729
  // `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
714
730
  // proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
715
- // lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
731
+ // list_ tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
716
732
  const devices = await Promise.all(issued.map(async (x) => ({
717
733
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
718
734
  })))
@@ -1043,7 +1059,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1043
1059
  * NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
1044
1060
  *
1045
1061
  * El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
1046
- * sin contenido: quien lo recibe pregunta por la lista, no la lee del aviso.
1062
+ * sin contenido: quien lo recibe pregunta por la list_, no la lee del aviso.
1047
1063
  */
1048
1064
  async function notifyApprovers (pend, record) {
1049
1065
  try {
@@ -1154,7 +1170,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1154
1170
  cek: await passwordsKey(),
1155
1171
  // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
1156
1172
  // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
1157
- // que haya una segunda lista que acordarse de tocar.
1173
+ // que haya una segunda list_ que acordarse de tocar.
1158
1174
  isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
1159
1175
  encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
1160
1176
  // EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
@@ -1332,11 +1348,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1332
1348
  // Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
1333
1349
  // eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
1334
1350
  // `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
1335
- // acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
1351
+ // acta nueva (admitir un aparato con su permiso `sella`), su list_ de emitidas no
1336
1352
  // tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
1337
1353
  // quedaban con actas distintas y la del dueño sin enterarse de nada.
1338
1354
  //
1339
- // El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
1355
+ // El acta es la list_ de quién es del perfil: esa es la list_ correcta. `issued` se
1340
1356
  // suma porque cubre a quien tiene cert y todavía no aparece ahí.
1341
1357
  const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
1342
1358
  const yo = identity.me?.publickey || null
@@ -1401,11 +1417,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1401
1417
  // podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
1402
1418
  // propósito: que uno falle no basta para leerlas.
1403
1419
  //
1404
- // Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
1420
+ // Se sella la list_ ENTERA, no solo los valores: el proxy tampoco tiene por qué
1405
1421
  // aprender cómo se llaman tus variables ni qué servicios corres.
1406
1422
  return {
1407
1423
  enc: await identity.sealContent(JSON.stringify({
1408
- ns: conSobres(listSecrets(), 'ns', caller), dev: conSobres(await listDeviceSecrets(), 'dev', caller),
1424
+ ns: withEnvelopes(listSecrets(), 'ns', caller), dev: withEnvelopes(await listDeviceSecrets(), 'dev', caller),
1409
1425
  // Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
1410
1426
  // enseñarlo donde se administra, no solo en el log del servicio.
1411
1427
  incomplete: await incompleteMembers(),
@@ -1422,7 +1438,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1422
1438
  }
1423
1439
  },
1424
1440
  /**
1425
- * PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la lista sale del ACTA
1441
+ * PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la list_ sale del ACTA
1426
1442
  * (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
1427
1443
  * exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
1428
1444
  */
@@ -1459,8 +1475,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1459
1475
  // PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
1460
1476
  // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
1461
1477
  // marca solo decide si se entrega sin aprobación.
1462
- await verificarAutor(owner, key, sealed, caller)
1463
- await verificarDestinatarios(owner, sealed.wraps, !!isPublic)
1478
+ await checkAuthor(owner, key, sealed, caller)
1479
+ await checkRecipients(owner, sealed.wraps, !!isPublic)
1464
1480
  await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
1465
1481
  audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
1466
1482
  await settleDebts(owner)
@@ -1499,8 +1515,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1499
1515
  const keys = []
1500
1516
  for (const it of items) {
1501
1517
  if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
1502
- await verificarAutor(owner, it.key, it.sealed, caller)
1503
- await verificarDestinatarios(owner, it.sealed.wraps, !!it.public)
1518
+ await checkAuthor(owner, it.key, it.sealed, caller)
1519
+ await checkRecipients(owner, it.sealed.wraps, !!it.public)
1504
1520
  }
1505
1521
  // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
1506
1522
  // configuración que nadie quiso, y el servicio se reinicia con ella.
@@ -1606,7 +1622,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1606
1622
  // listar y borrar no la piden (ver `secretsStore.js`).
1607
1623
  /**
1608
1624
  * Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
1609
- * (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
1625
+ * (miembros del acta con ese `cn`). La bóveda NO entra en la list_ — envolverle la
1610
1626
  * CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
1611
1627
  * que este diseño quita.
1612
1628
  */
@@ -1614,7 +1630,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1614
1630
  const record = (await identity.profileActa?.().catch(() => null))?.acta
1615
1631
  if (!record) return []
1616
1632
  // LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
1617
- // lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
1633
+ // list_ si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
1618
1634
  //
1619
1635
  // Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
1620
1636
  // DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
@@ -1632,7 +1648,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1632
1648
  * ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
1633
1649
  * frase que se escribe en cualquier parte a una llave que no sale del aparato.
1634
1650
  *
1635
- * La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
1651
+ * La bóveda NO entra en la list_, ni aquí ni en `nsMembers`: envolverle la llave a
1636
1652
  * ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
1637
1653
  * diseño quita. Hay un test que lo afirma.
1638
1654
  */
@@ -1825,7 +1841,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1825
1841
  * hay quien la teclee.
1826
1842
  *
1827
1843
  * Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
1828
- * vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
1844
+ * vea: hasta ahora un servicio recién enrolado aparecía en la list_ como cualquier
1829
1845
  * otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
1830
1846
  * mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
1831
1847
  * (`dotrino-vault secret settle`, que sí pide la frase).
@@ -1844,7 +1860,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1844
1860
  // «Ver» de la consola le fallaría sin explicar por qué.
1845
1861
  // Un aparato que administra NO debe tener envoltura de los cajones con dueño
1846
1862
  // (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
1847
- // propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
1863
+ // propósito y ponerlo en la list_ sería pedir que se «arregle» lo correcto.
1848
1864
  const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
1849
1865
  const drawers = m.cn
1850
1866
  ? [`ns:${m.cn}`, `dev:${m.pub}`]
@@ -1944,7 +1960,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1944
1960
  * único que la frase guarda (§8.3).
1945
1961
  *
1946
1962
  * No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
1947
- * tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
1963
+ * tiene que seguir viéndose en la list_ en vez de tumbar la operación entera.
1948
1964
  */
1949
1965
  /**
1950
1966
  * PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
@@ -2077,7 +2093,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2077
2093
  * relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
2078
2094
  * las dos comprobaciones lo note.
2079
2095
  */
2080
- async function verificarAutor (owner, key, sealed, caller) {
2096
+ async function checkAuthor (owner, key, sealed, caller) {
2081
2097
  const a = sealed?.author
2082
2098
  if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
2083
2099
  throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
@@ -2126,7 +2142,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2126
2142
  *
2127
2143
  * Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
2128
2144
  */
2129
- async function verificarDestinatarios (owner, wraps, isPublic = false) {
2145
+ async function checkRecipients (owner, wraps, isPublic = false) {
2130
2146
  if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
2131
2147
  const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
2132
2148
  if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
@@ -2220,8 +2236,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2220
2236
  const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
2221
2237
  for (const owner of secrets.owners?.() || []) {
2222
2238
  const before = new Set(secrets.recipientsIn(owner))
2223
- // POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
2224
- // administra. Con una sola lista se le daría también la de una privada con dueño, que
2239
+ // POR VISIBILIDAD, no una list_ fija: una pública lleva además la envoltura de quien
2240
+ // administra. Con una sola list_ se le daría también la de una privada con dueño, que
2225
2241
  // es justo lo que no puede pasar.
2226
2242
  const members = (esPublica) => recipientsOf(owner, { public: esPublica })
2227
2243
  try {
@@ -2284,7 +2300,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2284
2300
  if (!owed) return null
2285
2301
  // QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
2286
2302
  //
2287
- // Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
2303
+ // Antes cada llamador traía su propia list_ (`nsMembers`, o el miembro suelto), y todas
2288
2304
  // se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
2289
2305
  // una variable envolvía para el servicio y dejaba al sellador con la generación vieja
2290
2306
  // (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
@@ -2390,20 +2406,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2390
2406
  * pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
2391
2407
  * un valor o esconder la fila sería peor.
2392
2408
  */
2393
- const conSobres = (lista, tipo, caller) => {
2394
- if (!caller) return lista
2409
+ const withEnvelopes = (list_, kind, caller) => {
2410
+ if (!caller) return list_
2395
2411
  const dar = (owner, k) => {
2396
2412
  if (!k.public) return k
2397
2413
  const e = secrets.entryOf?.(owner, k.key)
2398
2414
  const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
2399
2415
  return e?.e && wrap ? { ...k, e: e.e, wrap } : k
2400
2416
  }
2401
- if (tipo === 'ns') {
2417
+ if (kind === 'ns') {
2402
2418
  const out = {}
2403
- for (const [ns, keys] of Object.entries(lista)) out[ns] = keys.map((k) => dar(`ns:${ns}`, k))
2419
+ for (const [ns, keys] of Object.entries(list_)) out[ns] = keys.map((k) => dar(`ns:${ns}`, k))
2404
2420
  return out
2405
2421
  }
2406
- return lista.map((row) => ({ ...row, keys: row.keys.map((k) => dar(`dev:${row.pub}`, k)) }))
2422
+ return list_.map((row) => ({ ...row, keys: row.keys.map((k) => dar(`dev:${row.pub}`, k)) }))
2407
2423
  }
2408
2424
 
2409
2425
  function listSecrets () {
@@ -2523,16 +2539,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2523
2539
  async takeMasterKey () {
2524
2540
  const r = await identity.reloadMasterKey?.()
2525
2541
  if (r?.locked === false) { try { await identity.sealMasterKey?.() } catch (_) {} }
2542
+ // ABRIR ES EL ÚNICO MOMENTO en que un perfil con contraseña está abierto, así que es
2543
+ // aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
2544
+ // Si falla, no se arrastra: abrir el perfil no puede depender de esto.
2545
+ if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
2526
2546
  return { locked: r?.locked !== false }
2527
2547
  },
2528
2548
  startPairing: desk.startPairing,
2529
2549
  stopPairing: desk.stopPairing,
2530
2550
  listPending: desk.listPending,
2531
2551
  // ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
2532
- // (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
2552
+ // (`unattended`), no una list_ de esta máquina — por eso ya no hay `setApproval` ni
2533
2553
  // `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
2534
2554
  needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
2535
- // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
2555
+ // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se list_: es donde está la
2536
2556
  // llave. Lo que no puede es listarla un aparato.
2537
2557
  passwordDevices,
2538
2558
  passwordsVault: () => passwords?.vault || null,
@@ -2579,7 +2599,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2579
2599
  // desbloquear: deja `secrets.json.v3.bak` para poder volver.
2580
2600
  /**
2581
2601
  * Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
2582
- * normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
2602
+ * normal es NO pasarlo: por defecto se usa la misma list_ de destinatarios que
2583
2603
  * cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
2584
2604
  *
2585
2605
  * Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
@@ -2624,7 +2644,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2624
2644
  isMaster: () => identity.isMaster(),
2625
2645
  setCaps: async (pub, caps) => {
2626
2646
  const r = await identity.setCaps(pub, caps)
2627
- // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
2647
+ // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una list_ CERRADA de permisos y
2628
2648
  // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
2629
2649
  // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
2630
2650
  // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un