@dotrino/vaultd 0.52.0 → 0.53.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/README.md CHANGED
@@ -456,18 +456,25 @@ y así un reinicio del PC nunca deja tus apps muertas esperando a que alguien te
456
456
  ```sh
457
457
  dotrino-vault profile password # pone o cambia la contraseña (te la pregunta)
458
458
  dotrino-vault profile password rm # la quita
459
- dotrino-vault unlock # abre la bóveda en esta consola
460
- dotrino-vault lock # vuelve a cerrarla
459
+ dotrino-vault unlock # abre la bóveda en esta consola (5 min sin usarse y se cierra sola)
460
+ dotrino-vault lock # vuelve a cerrarla ya
461
461
  ```
462
462
 
463
463
  Lo único que se sigue viendo con el candado puesto es que **existe** y que está cerrada
464
464
  (`status`, `profile ls`): si no, no habría forma de saber qué abrir.
465
465
 
466
- El perfil se vuelve a bloquear al reiniciar el servicio. La contraseña **no se
467
- guarda**: solo un verificador con sal (PBKDF2), igual que el candado del navegador.
468
- Tiene un mínimo de 4 caracteres y, tras 5 intentos fallidos, cada intento nuevo
469
- espera cada vez más (hasta 5 minutos); la cuenta de fallos se guarda, así que
470
- reiniciar no la borra.
466
+ **Se vuelve a cerrar solo a los 5 minutos sin usarse**, además de al reiniciar el
467
+ servicio o con `dotrino-vault lock`. El plazo se cuenta desde la última cosa que hiciste
468
+ en la consola, no desde que la abriste: mientras trabajas no te echa, y en cuanto te
469
+ levantas de la silla se cierra. Lo que piden tus aparatos por el proxy **no** cuenta como
470
+ uso —el candado no es suyo—, así que siguen funcionando igual mientras la consola se
471
+ cierra. La TUI, además, **olvida la contraseña** en ese momento: si no, la reabriría sola
472
+ a la siguiente tecla.
473
+
474
+ La contraseña **no se guarda**: solo un verificador con sal (scrypt), igual que el
475
+ candado del navegador. Tiene un mínimo de 12 caracteres —varias palabras al azar— y,
476
+ tras 5 intentos fallidos, cada intento nuevo espera cada vez más (hasta 5 minutos); la
477
+ cuenta de fallos se guarda, así que reiniciar no la borra.
471
478
 
472
479
  Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha: cualquier
473
480
  comando que mire o toque esa bóveda falla con «perfil bloqueado» y hay que correr
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.52.0",
3
+ "version": "0.53.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
@@ -1141,8 +1141,13 @@ function askText (prompt) {
1141
1141
 
1142
1142
  async function cmdUnlock () {
1143
1143
  const pwd = await askPassword('Contraseña del perfil: ')
1144
- reportProfiles(await profileRequest('unlock', { password: pwd }))
1145
- console.log('Ya puedes editar el perfil. Se vuelve a bloquear al reiniciar el servicio (o con: dotrino-vault lock).')
1144
+ // El daemon devuelve cuánto aguanta abierto: se dice AQUÍ, al abrirlo, que es cuando
1145
+ // sirve de algo. Encontrárselo cerrado sin haberlo leído nunca parece una avería.
1146
+ const out = await profileRequest('unlock', { password: pwd })
1147
+ reportProfiles(out)
1148
+ const min = Math.max(1, Math.round((out?.autoLockMs || 5 * 60 * 1000) / 60000))
1149
+ console.log(`Ya puedes editar el perfil. Se vuelve a bloquear solo tras ${min} min sin usarse` +
1150
+ ' (o al reiniciar el servicio, o con: dotrino-vault lock).')
1146
1151
  }
1147
1152
 
1148
1153
  async function cmdLock () {
@@ -1231,7 +1236,8 @@ dispositivos siguen firmando y leyendo aunque esté bloqueado):
1231
1236
  profile password [set] pone o cambia la contraseña
1232
1237
  profile password rm la quita
1233
1238
  unlock desbloquea para poder editar
1234
- lock vuelve a bloquear (también al reiniciar el servicio)
1239
+ lock vuelve a bloquear (también solo, a los 5 min sin
1240
+ usarse, y al reiniciar el servicio)
1235
1241
 
1236
1242
  Arrancar y parar, según dónde corra:
1237
1243
  Linux (servicio) systemctl --user {start,stop,restart} dotrino-vault
package/src/daemon.js CHANGED
@@ -74,6 +74,9 @@ export async function runDaemon () {
74
74
  writeJson(stateFile, {
75
75
  v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
76
76
  proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
77
+ // Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
78
+ // pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
79
+ autoLockMs: mgr.profiles.autoLockMs,
77
80
  current: mgr.currentId(), profiles: mgr.summary()
78
81
  })
79
82
  }
@@ -105,7 +108,14 @@ export async function runDaemon () {
105
108
  const resolveTarget = (req) => {
106
109
  try {
107
110
  const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
108
- return { id, vault: mgr.get(id), locked: mgr.profiles.isLocked(id) }
111
+ const locked = mgr.profiles.isLocked(id)
112
+ // ESTO CUENTA COMO USO: el candado se cierra solo a los 5 min de no usarse
113
+ // (`profiles.js`), y quien lo usa es esta consola. Va aquí y no en cada `case`
114
+ // porque toda petición de la CLI/TUI pasa por este punto, y así ninguna se olvida.
115
+ // Lo que un aparato pida por el proxy NO pasa por aquí, que es justo lo que se
116
+ // quiere: el candado no es suyo y no debe alargarlo.
117
+ if (!locked) mgr.profiles.touch(id)
118
+ return { id, vault: mgr.get(id), locked }
109
119
  } catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
110
120
  }
111
121
  /** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
@@ -247,8 +257,24 @@ export async function runDaemon () {
247
257
  if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
248
258
  }
249
259
 
260
+ /**
261
+ * BORRA la llave derivada en cuanto se usó. Es lo único de la contraseña que se puede
262
+ * borrar de verdad: un `string` de JS no se puede pisar (lo copia y lo mueve el motor
263
+ * hasta que pase el recolector), pero un `Uint8Array` sí, y es el que lleva el material
264
+ * con el que se abre la copia maestra. Vale lo que vale — no hace milagros con un
265
+ * volcado de memoria tomado en el instante justo —, pero acorta la ventana de horas a
266
+ * milisegundos, que es la diferencia entre «estaba ahí» y «estuvo».
267
+ */
268
+ const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
269
+
250
270
  async function handleProfileRequest (req) {
251
- const ref = () => mgr.resolve(req.profile || mgr.currentId())
271
+ // Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
272
+ // bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
273
+ const ref = () => {
274
+ const id = mgr.resolve(req.profile || mgr.currentId())
275
+ mgr.profiles.touch(id)
276
+ return id
277
+ }
252
278
  switch (req.op) {
253
279
  case 'list': return {} // el volcado de perfiles ya se hace abajo
254
280
  // `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
@@ -266,8 +292,9 @@ export async function runDaemon () {
266
292
  const id = ref()
267
293
  await mgr.profiles.unlock(id, req.password)
268
294
  let note = ''
295
+ let ak = null
269
296
  try {
270
- const ak = await mgr.profiles.adminKey(id, req.password)
297
+ ak = await mgr.profiles.adminKey(id, req.password)
271
298
  // REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
272
299
  // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
273
300
  // reemplazando lo que alguien metiera mal y quitando lo que sobre.
@@ -277,7 +304,8 @@ export async function runDaemon () {
277
304
  if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
278
305
  else if (r?.wrapped) note = ' · llavero al día'
279
306
  } catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
280
- return { done: 'perfil desbloqueado' + note }
307
+ finally { wipe(ak) }
308
+ return { done: 'perfil desbloqueado' + note, autoLockMs: mgr.profiles.autoLockMs }
281
309
  }
282
310
  case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
283
311
  // PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
@@ -291,7 +319,7 @@ export async function runDaemon () {
291
319
  const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
292
320
  await mgr.profiles.setPassword(id, req.password)
293
321
  const nueva = await mgr.profiles.adminKey(id, req.password)
294
- await rekey(id, vieja, nueva)
322
+ try { await rekey(id, vieja, nueva) } finally { wipe(vieja); wipe(nueva) }
295
323
  return { done: 'contraseña guardada' }
296
324
  }
297
325
  // QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
@@ -301,7 +329,7 @@ export async function runDaemon () {
301
329
  const id = ref()
302
330
  if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
303
331
  const vieja = await mgr.profiles.adminKey(id, req.password)
304
- await rekey(id, vieja, null)
332
+ try { await rekey(id, vieja, null) } finally { wipe(vieja) }
305
333
  mgr.profiles.removePassword(id)
306
334
  return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
307
335
  }
@@ -373,6 +401,7 @@ export async function runDaemon () {
373
401
  if (sec?.op) {
374
402
  rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
375
403
  lastSecretError = null
404
+ let ak // fuera del try para poder BORRARLA pase lo que pase (ver `wipe`)
376
405
  try {
377
406
  const vault = targetOf(sec)
378
407
  // Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
@@ -384,7 +413,7 @@ export async function runDaemon () {
384
413
  // convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
385
414
  // a la llave de la máquina, que es la protección de antes de esto (y el vault lo
386
415
  // avisa al arrancar).
387
- const ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
416
+ ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
388
417
  if (sec.op === 'migrate') {
389
418
  // Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
390
419
  // escritura (servicios del cajón + aparatos que administran). Con una lista
@@ -440,7 +469,7 @@ export async function runDaemon () {
440
469
  code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
441
470
  }
442
471
  console.error('[vault] secret failed:', e.message)
443
- }
472
+ } finally { wipe(ak) }
444
473
  }
445
474
  // Perfiles / candado.
446
475
  const preq = readJsonSafe(profileReqFile)
package/src/manager.js CHANGED
@@ -35,14 +35,20 @@ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
35
35
  throw e
36
36
  }
37
37
 
38
- export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
38
+ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, autoLockMs } = {}) {
39
39
  ensureDir(root)
40
40
  // El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
41
41
  // se instala apuntando a la RAÍZ (no al dir de un perfil) para que los perfiles
42
42
  // no se peleen por el archivo ni se lo lleven al borrarse.
43
43
  installNodeGlobals(root)
44
44
 
45
- const profiles = openProfiles(root)
45
+ const profiles = openProfiles(root, {
46
+ ...(autoLockMs === undefined ? {} : { autoLockMs }),
47
+ // Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
48
+ // pidiendo la contraseña otra vez cree que algo se rompió.
49
+ onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
50
+ id, Math.round(profiles.autoLockMs / 60000))
51
+ })
46
52
  const migrated = profiles.migrate()
47
53
  if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
48
54
 
package/src/profiles.js CHANGED
@@ -52,6 +52,25 @@ const PWD_MIN = 12
52
52
  */
53
53
  const TRIES_FORGET_MS = 15 * 60 * 1000
54
54
 
55
+ /**
56
+ * BLOQUEO AUTOMÁTICO. Cuánto aguanta abierto el candado sin que nadie lo use.
57
+ *
58
+ * Abrir la bóveda dejaba la consola abierta hasta que alguien la cerraba a mano o
59
+ * reiniciaba el servicio — y el servicio de un PC de escritorio no se reinicia en semanas.
60
+ * O sea que teclear la contraseña una vez un lunes dejaba la máquina administrable para
61
+ * quien pasara por delante el jueves. Un candado que solo se cierra reiniciando no es un
62
+ * candado.
63
+ *
64
+ * El plazo se cuenta desde el ÚLTIMO USO (`touch`), no desde que se abrió: quien está
65
+ * trabajando no se queda fuera a media faena, y quien se levanta de la silla lo encuentra
66
+ * cerrado. Y vence solo, sin temporizador: se mira la hora al preguntar (`isOpen`), así
67
+ * que no hay nada que limpiar ni un `setTimeout` que mantenga vivo al proceso.
68
+ *
69
+ * Como todo el candado, esto es de la CONSOLA: al vencer, los aparatos ya emparejados
70
+ * siguen firmando, leyendo y guardando. Lo que se cierra es administrar y mirar.
71
+ */
72
+ const AUTO_LOCK_MS = 5 * 60 * 1000
73
+
55
74
  /**
56
75
  * Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
57
76
  *
@@ -91,7 +110,13 @@ async function derivePwd (password, saltB64, iter) {
91
110
  const newId = () => 'p' + crypto.randomUUID().slice(0, 8)
92
111
  const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
93
112
 
94
- export function openProfiles (root = dataDir()) {
113
+ /**
114
+ * @param {string} root dir de datos
115
+ * @param {{ autoLockMs?: number, onAutoLock?: (id: string) => void }} [opts]
116
+ * `autoLockMs`: 0 desactiva el bloqueo automático (las pruebas lo acortan).
117
+ * `onAutoLock`: se avisa cuando un perfil se cierra solo, para poder decirlo en el log.
118
+ */
119
+ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onAutoLock = null } = {}) {
95
120
  const file = path.join(root, REGISTRY)
96
121
  // CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
97
122
  // lleva dentro el verificador del candado. No protege de quien tenga el disco entero
@@ -107,8 +132,24 @@ export function openProfiles (root = dataDir()) {
107
132
  const save = () => writeJson(file, data, atRest)
108
133
 
109
134
  // Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
110
- // vuelve a bloquear, igual que cerrar la pestaña en el navegador).
111
- const unlocked = new Set()
135
+ // vuelve a bloquear, igual que cerrar la pestaña en el navegador), cada uno con la
136
+ // hora a la que se cierra solo si nadie lo usa (ver AUTO_LOCK_MS).
137
+ const unlocked = new Map() // id -> vence (ms epoch)
138
+
139
+ /** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
140
+ const isOpen = (id) => {
141
+ const until = unlocked.get(id)
142
+ if (until == null) return false
143
+ if (autoLockMs > 0 && Date.now() >= until) {
144
+ unlocked.delete(id)
145
+ // El aviso va después de borrarlo: quien lo escuche verá el perfil ya cerrado.
146
+ try { onAutoLock?.(id) } catch (_) {}
147
+ return false
148
+ }
149
+ return true
150
+ }
151
+ /** Abre (o estira) el plazo. Todo lo que abre el candado pasa por aquí. */
152
+ const open = (id) => { unlocked.set(id, Date.now() + (autoLockMs > 0 ? autoLockMs : Number.MAX_SAFE_INTEGER)) }
112
153
 
113
154
  const find = (id) => data.profiles.find((p) => p.id === id) || null
114
155
  const dirOf = (id) => path.join(root, 'p', id)
@@ -118,7 +159,10 @@ export function openProfiles (root = dataDir()) {
118
159
  name: p.name || '',
119
160
  createdAt: p.createdAt || null,
120
161
  protected: !!p.pwd,
121
- locked: !!p.pwd && !unlocked.has(p.id),
162
+ locked: !!p.pwd && !isOpen(p.id),
163
+ // Hasta cuándo sigue abierto si nadie lo toca. Es DATO: la consola lo enseña para
164
+ // que el cierre no llegue por sorpresa.
165
+ ...(p.pwd && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
122
166
  current: p.id === data.current,
123
167
  // Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
124
168
  ...(p.adopt ? { adopt: true } : {})
@@ -231,7 +275,20 @@ export function openProfiles (root = dataDir()) {
231
275
  // ----- candado -----
232
276
 
233
277
  isProtected: (id) => !!find(id)?.pwd,
234
- isLocked: (id) => { const p = find(id); return !!p?.pwd && !unlocked.has(id) },
278
+ isLocked: (id) => { const p = find(id); return !!p?.pwd && !isOpen(id) },
279
+ /** Cuánto dura abierto el candado sin usarse (ms). 0 = no se cierra solo. */
280
+ get autoLockMs () { return autoLockMs },
281
+ /**
282
+ * «Se acaba de usar»: estira el plazo del bloqueo automático. Lo llama la consola
283
+ * al atender CADA petición suya, y solo eso cuenta como uso: que un aparato pida su
284
+ * configuración no abre nada ni alarga nada, porque el candado no es suyo.
285
+ * Devuelve si el perfil estaba abierto (a uno cerrado no hay nada que estirarle).
286
+ */
287
+ touch (id) {
288
+ if (!isOpen(id)) return false
289
+ open(id)
290
+ return true
291
+ },
235
292
  assertUnlocked (id) {
236
293
  if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
237
294
  },
@@ -258,7 +315,7 @@ export function openProfiles (root = dataDir()) {
258
315
 
259
316
  async unlock (id, password) {
260
317
  const p = assertExists(id)
261
- if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
318
+ if (!p.pwd) { open(id); return { ok: true, locked: false } }
262
319
  // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
263
320
  // fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
264
321
  // Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
@@ -296,7 +353,7 @@ export function openProfiles (root = dataDir()) {
296
353
  }
297
354
  delete p.tries
298
355
  save()
299
- unlocked.add(id)
356
+ open(id)
300
357
  return { ok: true, locked: false }
301
358
  },
302
359
 
@@ -314,7 +371,7 @@ export function openProfiles (root = dataDir()) {
314
371
  p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
315
372
  delete p.tries
316
373
  save()
317
- unlocked.add(id)
374
+ open(id)
318
375
  return entry(p)
319
376
  },
320
377
 
@@ -324,7 +381,7 @@ export function openProfiles (root = dataDir()) {
324
381
  delete p.pwd
325
382
  delete p.tries
326
383
  save()
327
- unlocked.add(id)
384
+ open(id)
328
385
  return entry(p)
329
386
  }
330
387
  }
package/src/tui/app.js CHANGED
@@ -492,6 +492,72 @@ async function guard (term, st, msg, fn) {
492
492
  */
493
493
  const activeLocked = (st) => { const p = activeProfile(st); return !!(p?.protected && p.locked) }
494
494
 
495
+ /** Los minutos que el daemon aguanta abierto (viene en `state.json`; 5 por defecto). */
496
+ const autoLockMin = (st) => Math.max(1, Math.round((st.state?.autoLockMs || 5 * 60 * 1000) / 60000))
497
+
498
+ /**
499
+ * ¿Alguna de las bóvedas que abrió ESTA sesión se cerró sola?
500
+ *
501
+ * El plazo lo lleva el daemon (`profiles.js`), no la TUI: aquí solo se MIRA su foto
502
+ * (`state.json`, que se reescribe cada dos segundos). Así hay un único reloj y no dos
503
+ * que se desincronizan — y lo que decida el daemon es lo que manda, porque es quien
504
+ * atiende.
505
+ */
506
+ const autoLockedIds = (st) => (st.state?.profiles || [])
507
+ .filter((p) => p.protected && p.locked && (st.sessionPwd?.has(p.id) || st.unlockedHere?.has(p.id)))
508
+ .map((p) => p.id)
509
+
510
+ /**
511
+ * Cuándo hay que despertar para enterarse. Sin esto la TUI se queda dormida en
512
+ * `readKey()` hasta la siguiente tecla y seguiría enseñando los aparatos y los nombres
513
+ * de las variables de una bóveda que el daemon ya cerró. Se suman ~2 s porque la foto
514
+ * se reescribe cada dos: despertar en el instante justo leería la anterior.
515
+ */
516
+ function autoLockWakeIn (st, now = Date.now()) {
517
+ let at = 0
518
+ for (const p of st.state?.profiles || []) {
519
+ if (!p.until || !(st.sessionPwd?.has(p.id) || st.unlockedHere?.has(p.id))) continue
520
+ at = at ? Math.min(at, p.until) : p.until
521
+ }
522
+ if (!at) return 0
523
+ const left = at - now + 2200
524
+ // El plazo ya pasó y la foto sigue diciendo que está abierta: el daemon no la está
525
+ // reescribiendo (caído o colgado). Se insiste un rato y luego se deja de despertar, o
526
+ // la TUI se quedaría dando vueltas contra una foto que no va a cambiar.
527
+ if (left <= 0) return now - at > 15000 ? 0 : 1000
528
+ return Math.max(250, left)
529
+ }
530
+
531
+ /**
532
+ * BLOQUEO AUTOMÁTICO. Se cerró sola: se OLVIDA su contraseña y se sale de lo que se
533
+ * estaba mirando (dentro se ven los aparatos, las variables y tus datos, que es justo
534
+ * lo que el candado tapa).
535
+ *
536
+ * Olvidarla es la mitad que importa: sin esto la TUI la seguiría teniendo y la
537
+ * reabriría sola en cuanto alguien pulsara Enter (ver `reunlockSilently`), así que el
538
+ * candado del daemon no habría cerrado nada.
539
+ */
540
+ async function forgetAutoLocked (term, st, api = vc) {
541
+ const ids = autoLockedIds(st)
542
+ if (!ids.length) return false
543
+ for (const id of ids) { st.sessionPwd?.delete(id); st.unlockedHere?.delete(id) }
544
+ const cur = (st.state?.profiles || []).find((p) => p.current)
545
+ if (ids.includes(cur?.id) && st.screen !== 'profiles') {
546
+ // Un modal abierto (una pregunta a medias) también se cierra: quien lo dejó abierto
547
+ // ya no está delante.
548
+ st.input = null
549
+ st.confirm = null
550
+ st.screen = 'profiles'
551
+ }
552
+ // Suelta lo cargado y repinta la lista con el candado ya echado (`refreshAll` no pide
553
+ // el contenido de una bóveda cerrada).
554
+ await refreshAll(term, st, api)
555
+ // El aviso va DESPUÉS del refresco: si no, un tropiezo al refrescar lo pisaría con su
556
+ // error rojo y el candado se cerraría en silencio.
557
+ flash(st, L(st).autoLocked(autoLockMin(st)), 'warn')
558
+ return true
559
+ }
560
+
495
561
  // `api` es `vaultControl` — se recibe para poder probar ESTA función (la que se rompió)
496
562
  // sin un daemon detrás, que es donde vive la regla de qué se pide y en qué orden.
497
563
  async function refreshAll (term, st, api = vc) {
@@ -1608,14 +1674,23 @@ export async function runTui () {
1608
1674
  while (running) {
1609
1675
  st.state = vc.readState()
1610
1676
  st.daemonUp = vc.daemonAlive()
1677
+ // ¿Se cerró sola mientras nadie miraba? Olvida su contraseña y sal de su contenido.
1678
+ await forgetAutoLocked(term, st)
1611
1679
  // caducar el flash a los ~4 s
1612
1680
  if (st.flash && Date.now() - st.flash.at > 4000) st.flash = null
1613
1681
  render(term, st)
1614
1682
 
1615
- const tick = (st.screen === 'pairing' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
1683
+ const base = (st.screen === 'pairing' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
1684
+ // Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
1685
+ const wake = autoLockWakeIn(st)
1686
+ const tick = base && wake ? Math.min(base, wake) : (base || wake)
1616
1687
  const key = await term.readKey(tick)
1617
1688
 
1618
1689
  if (key.name === 'resize') continue
1690
+ // Un despertar del bloqueo automático no es una tecla: ya se atendió arriba, y las
1691
+ // pantallas que NO esperan `tick` (todas menos aparatos y emparejamiento) no tienen
1692
+ // por qué verlo.
1693
+ if (key.name === 'tick' && st.screen !== 'pairing' && st.screen !== 'devices') continue
1619
1694
  // input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
1620
1695
  // Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
1621
1696
  if (st.input) { await onInputKey(st, key); continue }
@@ -1664,4 +1739,4 @@ export async function runTui () {
1664
1739
  }
1665
1740
 
1666
1741
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
1667
- export const __test = { render, activeLocked, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
1742
+ export const __test = { render, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
package/src/tui/i18n.js CHANGED
@@ -126,6 +126,7 @@ const es = {
126
126
  vaultUnlocked: 'Bóveda desbloqueada',
127
127
  lockingVault: 'Bloqueando…',
128
128
  vaultLocked: 'Bóveda bloqueada',
129
+ autoLocked: (min) => `Se bloqueó sola tras ${min} min sin usarse · la contraseña se olvidó`,
129
130
 
130
131
  // dispositivos
131
132
  pendingDevice: (id) => ` ⧗ PENDIENTE: ${id}`,
@@ -422,6 +423,7 @@ const en = {
422
423
  vaultUnlocked: 'Vault unlocked',
423
424
  lockingVault: 'Locking…',
424
425
  vaultLocked: 'Vault locked',
426
+ autoLocked: (min) => `Locked itself after ${min} min idle · the password was forgotten`,
425
427
 
426
428
  pendingDevice: (id) => ` ⧗ PENDING: ${id}`,
427
429
  pendingHint: ' — press A to approve, X to reject',