@dotrino/vaultd 0.93.0 → 0.94.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/ctl.js +69 -14
  3. package/src/vault.js +94 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.93.0",
3
+ "version": "0.94.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
@@ -24,6 +24,7 @@ import fs from 'node:fs'
24
24
  import path from 'node:path'
25
25
  import { execFileSync } from 'node:child_process'
26
26
  import tty from 'node:tty'
27
+ import crypto from 'node:crypto'
27
28
  import { pubkeyId } from '@dotrino/identity/capabilities'
28
29
  import { dataDir } from './paths.js'
29
30
  // EL CANAL LOCAL VA CIFRADO (ver `ipc.js`): por aquí salen la contraseña y los valores.
@@ -573,7 +574,7 @@ async function cmdMembers () {
573
574
  m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
574
575
  marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
575
576
  // 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 where_
577
+ // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
577
578
  // se mira quién es quién — y en la lista de variables ya seria tarde.
578
579
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
579
580
  }
@@ -820,11 +821,11 @@ function cmdAtrest (rest) {
820
821
  // versión vieja dejó en claro, con el verificador de la contraseña dentro— meses
821
822
  // después de que su migración terminara. Se mira el perfil Y la raíz, porque el canal
822
823
  // local con la CLI vive arriba.
823
- for (const [where_, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
824
+ for (const [label, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
824
825
  const loose = plaintextFilesIn(d)
825
826
  if (loose.length) {
826
827
  console.log('')
827
- console.log(`${R}En claro en ${where_}:${Z} ` + loose.join(', '))
828
+ console.log(`${R}En claro en ${label}:${Z} ` + loose.join(', '))
828
829
  console.log(' Si es un respaldo de una migración terminada, bórralo: no lo cifra nadie.')
829
830
  }
830
831
  }
@@ -926,16 +927,66 @@ function cmdAtrest (rest) {
926
927
  }
927
928
 
928
929
  // Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
929
- function cmdActivity (n = 30) {
930
- const f = path.join(profileDir(), 'activity.log')
931
- let lines = []
932
- try { lines = fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) } catch {
930
+ /**
931
+ * Toda la bitácora EN ORDEN, archivos rotados incluidos. Se leen del más viejo al más
932
+ * nuevo (`.5` … `.1`, y al final el vivo) porque la cadena los cruza: verificarla mirando
933
+ * solo el archivo actual diría «rota» en su primera línea, que es exactamente lo contrario
934
+ * de lo que pasa.
935
+ */
936
+ function readActivity (dir) {
937
+ const atRest = atRestFor(dir)
938
+ const out = []
939
+ const files = []
940
+ for (let i = 9; i >= 1; i--) { const f = path.join(dir, `activity.log.${i}`); if (fs.existsSync(f)) files.push(f) }
941
+ files.push(path.join(dir, 'activity.log'))
942
+ for (const f of files) {
943
+ let raw = ''
944
+ try { raw = fs.readFileSync(f, 'utf8') } catch { continue }
945
+ for (const l of raw.trim().split('\n').filter(Boolean)) {
946
+ // `decrypt` deja pasar el texto en claro, así que una bitácora anterior a 0.89 se
947
+ // sigue leyendo entera.
948
+ try { out.push(atRest.decrypt(l)) } catch { out.push(l) }
949
+ }
950
+ }
951
+ return out
952
+ }
953
+
954
+ /**
955
+ * ¿ESTÁ LA CADENA ENTERA? Cada entrada lleva el hash de la anterior, así que tocar o quitar
956
+ * una rompe todo lo que va detrás. Esto lo recorre y dice dónde se rompió.
957
+ *
958
+ * Lo que NO comprueba, y se dice: que no le hayan cortado el FINAL. Un prefijo válido sigue
959
+ * siendo válido; cerrarlo pide anclar el último hash fuera de esta máquina.
960
+ */
961
+ function verifyActivity (lines) {
962
+ const sha256 = (t) => crypto.createHash('sha256').update(t, 'utf8').digest('hex')
963
+ let prev = null; let seq = null; let checked = 0; let unchained = 0
964
+ for (const text of lines) {
965
+ let e; try { e = JSON.parse(text) } catch { continue }
966
+ if (typeof e.seq !== 'number') { unchained++; continue } // entradas de antes de 0.94
967
+ if (seq !== null) {
968
+ if (e.seq !== seq + 1) return { ok: false, at: e.seq, why: `salta del #${seq} al #${e.seq}` }
969
+ if (e.prev !== prev) return { ok: false, at: e.seq, why: `el #${e.seq} no encadena con el #${seq}` }
970
+ }
971
+ seq = e.seq; prev = sha256(text); checked++
972
+ }
973
+ return { ok: true, checked, unchained, last: seq }
974
+ }
975
+
976
+ function cmdActivity (n = 30, { verify = false, exportar = false } = {}) {
977
+ const dir = profileDir()
978
+ const lines = readActivity(dir)
979
+ if (!lines.length) {
933
980
  console.log('Sin actividad registrada todavía (o el servicio es anterior a 0.1.10).'); return
934
981
  }
935
- // Cada entrada va cifrada por separado (ver `audit` en `vault.js`). `decrypt` deja pasar
936
- // el texto en claro, así que una bitácora anterior a 0.89 se sigue leyendo entera.
937
- const atRest = atRestFor(profileDir())
938
- lines = lines.map((l) => { try { return atRest.decrypt(l) } catch { return l } })
982
+ if (exportar) { for (const l of lines) console.log(l); return }
983
+ if (verify) {
984
+ const r = verifyActivity(lines)
985
+ if (!r.ok) { console.error(`${R}CADENA ROTA${Z} en la entrada #${r.at}: ${r.why}`); process.exit(1) }
986
+ console.log(`Cadena íntegra: ${r.checked} entrada(s) encadenada(s)${r.last ? ` hasta la #${r.last}` : ''}.`)
987
+ if (r.unchained) console.log(` ${r.unchained} entrada(s) anteriores a 0.94, sin encadenar: no se pueden verificar.`)
988
+ return
989
+ }
939
990
  const ICON = { sign: '🖊 firma', renew: '♻ renovación', enroll: '➕ enrolado', revoke: '⛔ revocado', rejected: '🚫 RECHAZADO', secrets: '🔑 secretos leídos', 'secret.set': '🔑 secreto guardado', 'secret.rm': '🔑 secreto borrado' }
940
991
  for (const line of lines.slice(-n)) {
941
992
  try {
@@ -958,7 +1009,7 @@ function cmdActivity (n = 30) {
958
1009
  /**
959
1010
  * `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
960
1011
  * son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
961
- * where_ el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
1012
+ * donde el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
962
1013
  */
963
1014
  function asPairs (args) {
964
1015
  if (!args.length) return null
@@ -1395,7 +1446,7 @@ function reportProfiles (d) {
1395
1446
  async function cmdProfile (rest) {
1396
1447
  const [sub, ...rawArgs] = rest
1397
1448
  // `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
1398
- // los argumentos loose, así que si no se quita acabaría llamándose «midevault --kms
1449
+ // los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
1399
1450
  // cfg.json».
1400
1451
  const kmsAt = rawArgs.indexOf('--kms')
1401
1452
  const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
@@ -1610,6 +1661,8 @@ function help () {
1610
1661
  (--machine para volver a la clave de esta máquina). Editar
1611
1662
  atrest.json a mano NO vale: dejaría el perfil ilegible
1612
1663
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1664
+ activity --verify comprueba que la cadena está entera (nadie tocó el pasado)
1665
+ activity --export la vuelca entera en JSONL, para auditar o guardar fuera
1613
1666
  logs últimos logs del servicio
1614
1667
  version muestra la versión instalada
1615
1668
 
@@ -1664,7 +1717,9 @@ export async function runCtl (argv) {
1664
1717
  case 'secret': return cmdSecret(rest)
1665
1718
  case 'approval': return cmdApproval(rest)
1666
1719
  case 'atrest': return cmdAtrest(rest)
1667
- case 'activity': return cmdActivity(Number(rest[0]) || 30)
1720
+ case 'activity': return cmdActivity(Number(rest.find((a) => /^\d+$/.test(a))) || 30, {
1721
+ verify: rest.includes('--verify'), exportar: rest.includes('--export')
1722
+ })
1668
1723
  case 'logs': return cmdLogs()
1669
1724
  case 'version':
1670
1725
  case '--version':
package/src/vault.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * Toda la cripto es de `@dotrino/identity`. Este módulo solo orquesta.
12
12
  */
13
13
  import fs from 'node:fs'
14
+ import nodeCrypto from 'node:crypto'
14
15
  import path from 'node:path'
15
16
  import { Identity } from '@dotrino/identity/node'
16
17
  import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
@@ -119,7 +120,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
119
120
  * Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
120
121
  * no, espera a que un aparato con `approve` lo firme.
121
122
  *
122
- * Antes era al revés y vivía aquí: una list_ local de «estos SÍ piden permiso», así que
123
+ * Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
123
124
  * un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
124
125
  * omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
125
126
  * el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
@@ -135,7 +136,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
135
136
  }
136
137
  // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
137
138
  // 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 list_ de
139
+ // `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
139
140
  // aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
140
141
  // además un emparejamiento paralelo al del ecosistema.
141
142
  const passwordsFile = path.join(dir, 'passwords.json')
@@ -367,18 +368,73 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
367
368
  // Y el modo, que hasta 0.89 se creaba 0664: legible por cualquier usuario.
368
369
  if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600)
369
370
  } catch (_) { /* no hay bitácora todavía */ }
371
+ /**
372
+ * LA BITÁCORA ES UNA CADENA, no una lista de líneas sueltas.
373
+ *
374
+ * Cifrarla (0.89) le dio confidencialidad y ninguna integridad: quien tenga la llave de
375
+ * la máquina puede reescribir una línea y nadie lo nota. Como evidencia no valía nada, y
376
+ * eso es justo lo que un auditor mira (`CUMPLIMIENTO.md` §2).
377
+ *
378
+ * Cada entrada lleva su `seq` y el **hash de la anterior**, así que tocar o quitar una
379
+ * rompe la cadena de ahí en adelante y se ve. Se encadena el TEXTO exacto de la línea,
380
+ * no un objeto re-serializado: así verificar no depende de que las claves salgan en el
381
+ * mismo orden.
382
+ *
383
+ * **Lo que esto NO resuelve, dicho para no venderlo de más:** quien tenga la máquina
384
+ * puede cortar el final de la cadena y quedarse con un prefijo válido. Eso no se cierra
385
+ * en local — pide anclar el último hash fuera (un TSA, otro aparato). Lo que sí cierra
386
+ * es reescribir el pasado sin dejar rastro, que es el caso que importaba.
387
+ */
388
+ const AUDIT_MAX_BYTES = 1024 * 1024
389
+ /** Cuántos archivos viejos se guardan. Es la RETENCIÓN, y va declarada. */
390
+ const AUDIT_KEEP = 5
391
+ const sha256 = (text) => nodeCrypto.createHash('sha256').update(text, 'utf8').digest('hex')
392
+
393
+ let auditSeq = 0
394
+ let auditPrev = null
395
+
396
+ /** Reconstruye el estado de la cadena leyendo la última entrada que haya. */
397
+ try {
398
+ const lines = fs.readFileSync(activityFile, 'utf8').split('\n').filter(Boolean)
399
+ for (let i = lines.length - 1; i >= 0; i--) {
400
+ try {
401
+ const text = activityAtRest.decrypt(lines[i])
402
+ const e = JSON.parse(text)
403
+ if (typeof e.seq === 'number') { auditSeq = e.seq; auditPrev = sha256(text); break }
404
+ } catch (_) { /* una línea vieja o ilegible: se sigue hacia atrás */ }
405
+ }
406
+ } catch (_) { /* no hay bitácora todavía */ }
407
+
408
+ /**
409
+ * ROTA ARCHIVANDO, NO TIRANDO. Antes se quedaba con la mitad del archivo y la otra mitad
410
+ * desaparecía sin que nada lo dijera: una bitácora que pierde historia en silencio es
411
+ * peor que no tenerla, porque parece completa. Ahora los viejos se corren a `.1`…`.N` y,
412
+ * cuando uno se cae del borde, **queda anotado que se cayó**.
413
+ */
414
+ const rotateAudit = () => {
415
+ let dropped = null
416
+ const older = `${activityFile}.${AUDIT_KEEP}`
417
+ try { if (fs.existsSync(older)) { fs.rmSync(older); dropped = `activity.log.${AUDIT_KEEP}` } } catch (_) {}
418
+ for (let i = AUDIT_KEEP - 1; i >= 1; i--) {
419
+ try { if (fs.existsSync(`${activityFile}.${i}`)) fs.renameSync(`${activityFile}.${i}`, `${activityFile}.${i + 1}`) } catch (_) {}
420
+ }
421
+ try { fs.renameSync(activityFile, `${activityFile}.1`) } catch (_) {}
422
+ return dropped
423
+ }
424
+
370
425
  const audit = (op, info = {}) => {
371
426
  try {
372
- const line = activityAtRest.encrypt(JSON.stringify({ ts: Date.now(), op, ...info }))
373
- fs.appendFileSync(activityFile, line + '\n', { mode: 0o600 })
427
+ const entry = { seq: ++auditSeq, prev: auditPrev, ts: Date.now(), op, ...info }
428
+ const text = JSON.stringify(entry)
429
+ fs.appendFileSync(activityFile, activityAtRest.encrypt(text) + '\n', { mode: 0o600 })
430
+ auditPrev = sha256(text)
374
431
  // El `mode` de `appendFileSync` solo aplica al CREAR: las bitácoras de antes de
375
432
  // 0.89 ya existen con 0664 y hay que estrecharlas a mano.
376
433
  try { if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600) } catch (_) {}
377
- // rotación simple: si pasa de ~1 MB, conservar la última mitad
378
- const st = fs.statSync(activityFile)
379
- if (st.size > 1024 * 1024) {
380
- const lines = fs.readFileSync(activityFile, 'utf8').split('\n')
381
- fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'), { mode: 0o600 })
434
+ if (fs.statSync(activityFile).size > AUDIT_MAX_BYTES) {
435
+ const dropped = rotateAudit()
436
+ // La primera entrada del archivo nuevo dice de dónde viene, y si se perdió algo.
437
+ audit('log.rotated', { keep: AUDIT_KEEP, ...(dropped ? { dropped } : {}) })
382
438
  }
383
439
  } catch (_) {}
384
440
  }
@@ -464,7 +520,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
464
520
  *
465
521
  * Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
466
522
  * multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
467
- * los rechazaban los diez mostradores. Ahora se compara contra la list_ que dice el acta.
523
+ * los rechazaban los diez mostradores. Ahora se compara contra la lista que dice el acta.
468
524
  *
469
525
  * Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
470
526
  * mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
@@ -664,7 +720,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
664
720
  * exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
665
721
  *
666
722
  * La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
667
- * pidió, y contarle el perfil entero a quien no trae papel es dar de más—. Al que ya no
723
+ * pidió, y contarle el perfil entero a quien no trae papel es attach de más—. Al que ya no
668
724
  * está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
669
725
  * Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
670
726
  * nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
@@ -710,7 +766,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
710
766
  audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
711
767
  return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, acta: record })
712
768
  }
713
- // El acta viaja con la list_: así cada dispositivo se entera de los cambios de
769
+ // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
714
770
  // política (quién manda, quién puede qué) sin un canal aparte.
715
771
  // Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
716
772
  // que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
@@ -728,7 +784,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
728
784
  }
729
785
  // `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
730
786
  // proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
731
- // list_ tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
787
+ // lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
732
788
  const devices = await Promise.all(issued.map(async (x) => ({
733
789
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
734
790
  })))
@@ -773,7 +829,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
773
829
  const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
774
830
  // EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
775
831
  // decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
776
- // Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: dar
832
+ // Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: attach
777
833
  // `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
778
834
  // QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
779
835
  // Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
@@ -1059,7 +1115,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1059
1115
  * NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
1060
1116
  *
1061
1117
  * El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
1062
- * sin contenido: quien lo recibe pregunta por la list_, no la lee del aviso.
1118
+ * sin contenido: quien lo recibe pregunta por la lista, no la lee del aviso.
1063
1119
  */
1064
1120
  async function notifyApprovers (pend, record) {
1065
1121
  try {
@@ -1170,7 +1226,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1170
1226
  cek: await passwordsKey(),
1171
1227
  // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
1172
1228
  // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
1173
- // que haya una segunda list_ que acordarse de tocar.
1229
+ // que haya una segunda lista que acordarse de tocar.
1174
1230
  isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
1175
1231
  encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
1176
1232
  // EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
@@ -1348,11 +1404,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1348
1404
  // Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
1349
1405
  // eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
1350
1406
  // `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
1351
- // acta nueva (admitir un aparato con su permiso `sella`), su list_ de emitidas no
1407
+ // acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
1352
1408
  // tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
1353
1409
  // quedaban con actas distintas y la del dueño sin enterarse de nada.
1354
1410
  //
1355
- // El acta es la list_ de quién es del perfil: esa es la list_ correcta. `issued` se
1411
+ // El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
1356
1412
  // suma porque cubre a quien tiene cert y todavía no aparece ahí.
1357
1413
  const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
1358
1414
  const yo = identity.me?.publickey || null
@@ -1417,7 +1473,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1417
1473
  // podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
1418
1474
  // propósito: que uno falle no basta para leerlas.
1419
1475
  //
1420
- // Se sella la list_ ENTERA, no solo los valores: el proxy tampoco tiene por qué
1476
+ // Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
1421
1477
  // aprender cómo se llaman tus variables ni qué servicios corres.
1422
1478
  return {
1423
1479
  enc: await identity.sealContent(JSON.stringify({
@@ -1438,7 +1494,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1438
1494
  }
1439
1495
  },
1440
1496
  /**
1441
- * PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la list_ sale del ACTA
1497
+ * PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la lista sale del ACTA
1442
1498
  * (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
1443
1499
  * exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
1444
1500
  */
@@ -1622,7 +1678,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1622
1678
  // listar y borrar no la piden (ver `secretsStore.js`).
1623
1679
  /**
1624
1680
  * Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
1625
- * (miembros del acta con ese `cn`). La bóveda NO entra en la list_ — envolverle la
1681
+ * (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
1626
1682
  * CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
1627
1683
  * que este diseño quita.
1628
1684
  */
@@ -1630,7 +1686,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1630
1686
  const record = (await identity.profileActa?.().catch(() => null))?.acta
1631
1687
  if (!record) return []
1632
1688
  // LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
1633
- // list_ si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
1689
+ // lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
1634
1690
  //
1635
1691
  // Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
1636
1692
  // DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
@@ -1648,7 +1704,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1648
1704
  * ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
1649
1705
  * frase que se escribe en cualquier parte a una llave que no sale del aparato.
1650
1706
  *
1651
- * La bóveda NO entra en la list_, ni aquí ni en `nsMembers`: envolverle la llave a
1707
+ * La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
1652
1708
  * ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
1653
1709
  * diseño quita. Hay un test que lo afirma.
1654
1710
  */
@@ -1841,7 +1897,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1841
1897
  * hay quien la teclee.
1842
1898
  *
1843
1899
  * Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
1844
- * vea: hasta ahora un servicio recién enrolado aparecía en la list_ como cualquier
1900
+ * vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
1845
1901
  * otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
1846
1902
  * mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
1847
1903
  * (`dotrino-vault secret settle`, que sí pide la frase).
@@ -1860,7 +1916,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1860
1916
  // «Ver» de la consola le fallaría sin explicar por qué.
1861
1917
  // Un aparato que administra NO debe tener envoltura de los cajones con dueño
1862
1918
  // (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
1863
- // propósito y ponerlo en la list_ sería pedir que se «arregle» lo correcto.
1919
+ // propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
1864
1920
  const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
1865
1921
  const drawers = m.cn
1866
1922
  ? [`ns:${m.cn}`, `dev:${m.pub}`]
@@ -1960,7 +2016,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1960
2016
  * único que la frase guarda (§8.3).
1961
2017
  *
1962
2018
  * No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
1963
- * tiene que seguir viéndose en la list_ en vez de tumbar la operación entera.
2019
+ * tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
1964
2020
  */
1965
2021
  /**
1966
2022
  * PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
@@ -2236,8 +2292,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2236
2292
  const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
2237
2293
  for (const owner of secrets.owners?.() || []) {
2238
2294
  const before = new Set(secrets.recipientsIn(owner))
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
2295
+ // POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
2296
+ // administra. Con una sola lista se le daría también la de una privada con dueño, que
2241
2297
  // es justo lo que no puede pasar.
2242
2298
  const members = (esPublica) => recipientsOf(owner, { public: esPublica })
2243
2299
  try {
@@ -2300,7 +2356,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2300
2356
  if (!owed) return null
2301
2357
  // QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
2302
2358
  //
2303
- // Antes cada llamador traía su propia list_ (`nsMembers`, o el miembro suelto), y todas
2359
+ // Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
2304
2360
  // se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
2305
2361
  // una variable envolvía para el servicio y dejaba al sellador con la generación vieja
2306
2362
  // (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
@@ -2406,9 +2462,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2406
2462
  * pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
2407
2463
  * un valor o esconder la fila sería peor.
2408
2464
  */
2409
- const withEnvelopes = (list_, kind, caller) => {
2410
- if (!caller) return list_
2411
- const dar = (owner, k) => {
2465
+ const withEnvelopes = (entries, kind, caller) => {
2466
+ if (!caller) return entries
2467
+ const attach = (owner, k) => {
2412
2468
  if (!k.public) return k
2413
2469
  const e = secrets.entryOf?.(owner, k.key)
2414
2470
  const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
@@ -2416,10 +2472,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2416
2472
  }
2417
2473
  if (kind === 'ns') {
2418
2474
  const out = {}
2419
- for (const [ns, keys] of Object.entries(list_)) out[ns] = keys.map((k) => dar(`ns:${ns}`, k))
2475
+ for (const [ns, keys] of Object.entries(entries)) out[ns] = keys.map((k) => attach(`ns:${ns}`, k))
2420
2476
  return out
2421
2477
  }
2422
- return list_.map((row) => ({ ...row, keys: row.keys.map((k) => dar(`dev:${row.pub}`, k)) }))
2478
+ return entries.map((row) => ({ ...row, keys: row.keys.map((k) => attach(`dev:${row.pub}`, k)) }))
2423
2479
  }
2424
2480
 
2425
2481
  function listSecrets () {
@@ -2549,10 +2605,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2549
2605
  stopPairing: desk.stopPairing,
2550
2606
  listPending: desk.listPending,
2551
2607
  // ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
2552
- // (`unattended`), no una list_ de esta máquina — por eso ya no hay `setApproval` ni
2608
+ // (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
2553
2609
  // `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
2554
2610
  needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
2555
- // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se list_: es donde está la
2611
+ // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
2556
2612
  // llave. Lo que no puede es listarla un aparato.
2557
2613
  passwordDevices,
2558
2614
  passwordsVault: () => passwords?.vault || null,
@@ -2599,7 +2655,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2599
2655
  // desbloquear: deja `secrets.json.v3.bak` para poder volver.
2600
2656
  /**
2601
2657
  * Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
2602
- * normal es NO pasarlo: por defecto se usa la misma list_ de destinatarios que
2658
+ * normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
2603
2659
  * cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
2604
2660
  *
2605
2661
  * Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
@@ -2644,7 +2700,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2644
2700
  isMaster: () => identity.isMaster(),
2645
2701
  setCaps: async (pub, caps) => {
2646
2702
  const r = await identity.setCaps(pub, caps)
2647
- // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una list_ CERRADA de permisos y
2703
+ // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
2648
2704
  // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
2649
2705
  // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
2650
2706
  // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un