@dotrino/vaultd 0.52.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/profiles.js CHANGED
@@ -24,7 +24,9 @@ import fs from 'node:fs'
24
24
  import crypto2 from 'node:crypto'
25
25
  import path from 'node:path'
26
26
  import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
27
- import { atRestFor, migrateFile, machineKey } from './atrest.js'
27
+ import { atRestFor, migrateFile, kekFor } from './atrest.js'
28
+ import { probe as probeKek, writeConfig as writeKekConfig, configFromEnv } from './atrest.js'
29
+ import { keyDirName, keyOwnerOf } from './keyowner.js'
28
30
 
29
31
  const REGISTRY = 'profiles.json'
30
32
  const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
@@ -52,6 +54,25 @@ const PWD_MIN = 12
52
54
  */
53
55
  const TRIES_FORGET_MS = 15 * 60 * 1000
54
56
 
57
+ /**
58
+ * BLOQUEO AUTOMÁTICO. Cuánto aguanta abierto el candado sin que nadie lo use.
59
+ *
60
+ * Abrir la bóveda dejaba la consola abierta hasta que alguien la cerraba a mano o
61
+ * reiniciaba el servicio — y el servicio de un PC de escritorio no se reinicia en semanas.
62
+ * O sea que teclear la contraseña una vez un lunes dejaba la máquina administrable para
63
+ * quien pasara por delante el jueves. Un candado que solo se cierra reiniciando no es un
64
+ * candado.
65
+ *
66
+ * El plazo se cuenta desde el ÚLTIMO USO (`touch`), no desde que se abrió: quien está
67
+ * trabajando no se queda fuera a media faena, y quien se levanta de la silla lo encuentra
68
+ * cerrado. Y vence solo, sin temporizador: se mira la hora al preguntar (`isOpen`), así
69
+ * que no hay nada que limpiar ni un `setTimeout` que mantenga vivo al proceso.
70
+ *
71
+ * Como todo el candado, esto es de la CONSOLA: al vencer, los aparatos ya emparejados
72
+ * siguen firmando, leyendo y guardando. Lo que se cierra es administrar y mirar.
73
+ */
74
+ const AUTO_LOCK_MS = 5 * 60 * 1000
75
+
55
76
  /**
56
77
  * Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
57
78
  *
@@ -88,10 +109,25 @@ async function derivePwd (password, saltB64, iter) {
88
109
  return b64(bits)
89
110
  }
90
111
 
91
- const newId = () => 'p' + crypto.randomUUID().slice(0, 8)
112
+ /**
113
+ * El nombre de la carpeta DE PASO, la que existe solo mientras se acuña la llave.
114
+ *
115
+ * La llave se genera antes de escribirse (`crypto.subtle.generateKey` y luego el kv), pero
116
+ * la única API que tenemos —`Identity.connect({ dir })`— recibe la carpeta por delante y
117
+ * hace las dos cosas de un tirón: no hay costura donde meterse a preguntar la huella. Así
118
+ * que se acuña aquí y en cuanto se sabe de quién es, la carpeta se MUEVE a su nombre.
119
+ * El punto delante la deja fuera de la lista de perfiles si algo se corta a medias.
120
+ */
121
+ const stagingName = () => '.new-' + crypto.randomUUID().slice(0, 8)
92
122
  const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
93
123
 
94
- export function openProfiles (root = dataDir()) {
124
+ /**
125
+ * @param {string} root dir de datos
126
+ * @param {{ autoLockMs?: number, onAutoLock?: (id: string) => void }} [opts]
127
+ * `autoLockMs`: 0 desactiva el bloqueo automático (las pruebas lo acortan).
128
+ * `onAutoLock`: se avisa cuando un perfil se cierra solo, para poder decirlo en el log.
129
+ */
130
+ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onAutoLock = null } = {}) {
95
131
  const file = path.join(root, REGISTRY)
96
132
  // CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
97
133
  // lleva dentro el verificador del candado. No protege de quien tenga el disco entero
@@ -100,15 +136,46 @@ export function openProfiles (root = dataDir()) {
100
136
  // respaldo o en una carpeta compartida por descuido, que es lo que el códec cubre
101
137
  // para el resto. La migración verifica antes de reemplazar y es de una sola vez.
102
138
  ensureDir(root)
103
- try { migrateFile(file, machineKey(root)) } catch (_) {}
139
+ // LA RAÍZ TAMBIÉN NECESITA EL PROVEEDOR DEL ENTORNO, y esto lo destapó un contenedor:
140
+ // los perfiles nacían con la clave en el KMS, pero el REGISTRO de perfiles seguía con
141
+ // la de la máquina — así que al recrear el contenedor la bóveda no arrancaba igual,
142
+ // solo que fallando un paso antes. No guarda el contenido de ningún perfil (la lista y
143
+ // el verificador del candado), pero sin él no hay bóveda.
144
+ //
145
+ // Solo si está por estrenar: si el registro ya existe cifrado con la clave de antes,
146
+ // cambiarle el proveedor lo dejaría ilegible. Ahí se migra con `atrest rekey`.
147
+ if (!fs.existsSync(path.join(root, 'atrest.json'))) {
148
+ const kek = configFromEnv()
149
+ if (kek && !fs.existsSync(file)) {
150
+ probeKek(root, kek)
151
+ writeKekConfig(root, kek)
152
+ }
153
+ }
154
+ try { migrateFile(file, kekFor(root)) } catch (_) {}
104
155
  const atRest = atRestFor(root)
105
156
  let data = readJson(file, null, atRest)
106
157
  if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
107
158
  const save = () => writeJson(file, data, atRest)
108
159
 
109
160
  // 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()
161
+ // vuelve a bloquear, igual que cerrar la pestaña en el navegador), cada uno con la
162
+ // hora a la que se cierra solo si nadie lo usa (ver AUTO_LOCK_MS).
163
+ const unlocked = new Map() // id -> vence (ms epoch)
164
+
165
+ /** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
166
+ const isOpen = (id) => {
167
+ const until = unlocked.get(id)
168
+ if (until == null) return false
169
+ if (autoLockMs > 0 && Date.now() >= until) {
170
+ unlocked.delete(id)
171
+ // El aviso va después de borrarlo: quien lo escuche verá el perfil ya cerrado.
172
+ try { onAutoLock?.(id) } catch (_) {}
173
+ return false
174
+ }
175
+ return true
176
+ }
177
+ /** Abre (o estira) el plazo. Todo lo que abre el candado pasa por aquí. */
178
+ const open = (id) => { unlocked.set(id, Date.now() + (autoLockMs > 0 ? autoLockMs : Number.MAX_SAFE_INTEGER)) }
112
179
 
113
180
  const find = (id) => data.profiles.find((p) => p.id === id) || null
114
181
  const dirOf = (id) => path.join(root, 'p', id)
@@ -118,7 +185,10 @@ export function openProfiles (root = dataDir()) {
118
185
  name: p.name || '',
119
186
  createdAt: p.createdAt || null,
120
187
  protected: !!p.pwd,
121
- locked: !!p.pwd && !unlocked.has(p.id),
188
+ locked: !!p.pwd && !isOpen(p.id),
189
+ // Hasta cuándo sigue abierto si nadie lo toca. Es DATO: la consola lo enseña para
190
+ // que el cierre no llegue por sorpresa.
191
+ ...(p.pwd && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
122
192
  current: p.id === data.current,
123
193
  // Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
124
194
  ...(p.adopt ? { adopt: true } : {})
@@ -148,6 +218,13 @@ export function openProfiles (root = dataDir()) {
148
218
  const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
149
219
  if (hits.length === 1) return hits[0].id
150
220
  if (hits.length > 1) throw new Error(`there are ${hits.length} profiles named "${ref}"; use its id (dotrino-vault profile ls)`)
221
+ // POR PREFIJO. Desde que el id del perfil es el nombre de su carpeta —y ese sale de
222
+ // la llave— es largo, y nadie va a teclear 24 caracteres para decir `--profile`. El
223
+ // trozo que sí se lee y se reconoce es la huella de delante (`0571-465F`), que es la
224
+ // misma que sale en `members`. Ambiguo se rechaza, no se adivina.
225
+ const porPrefijo = data.profiles.filter((p) => p.id.toLowerCase().startsWith(needle))
226
+ if (porPrefijo.length === 1) return porPrefijo[0].id
227
+ if (porPrefijo.length > 1) throw new Error(`"${ref}" matches ${porPrefijo.length} profiles; give more of the id (dotrino-vault profile ls)`)
151
228
  throw new Error('profile does not exist: ' + ref)
152
229
  },
153
230
 
@@ -157,12 +234,18 @@ export function openProfiles (root = dataDir()) {
157
234
  * navegador, que adopta la identidad vieja como «Perfil 1»). `transport.json`
158
235
  * se queda en la raíz: es del proceso, no de la identidad.
159
236
  */
160
- migrate () {
237
+ async migrate (mintKey) {
161
238
  if (data.profiles.length) return null
162
239
  const legacy = fs.existsSync(path.join(root, 'identity.json'))
163
- const id = newId()
164
- const dir = dirOf(id)
165
- ensureDir(dir)
240
+ // EL PROVEEDOR DEL ENTORNO, y SOLO en una instalación nueva. Es lo que hace que un
241
+ // contenedor levantado con `DOTRINO_KMS_KEY_ID` tenga su primer perfil ya con la
242
+ // clave en el KMS, sin entrar a configurar nada.
243
+ //
244
+ // En una MIGRACIÓN no se toca, y el matiz importa: esos archivos ya están cifrados
245
+ // con la clave de la máquina, así que ponerles ahora un proveedor distinto los
246
+ // dejaría ilegibles —el guardia lo pararía, pero el usuario se quedaría con una
247
+ // bóveda que no arranca tras actualizar—. Ahí se migra con `atrest rekey`, a la vista.
248
+ const dir = api.stage({ fromEnv: !legacy })
166
249
  if (legacy) {
167
250
  for (const f of LEGACY_FILES) {
168
251
  const from = path.join(root, f)
@@ -173,12 +256,23 @@ export function openProfiles (root = dataDir()) {
173
256
  if (/^peers\..+\.json$/.test(f)) { try { fs.renameSync(path.join(root, f), path.join(dir, f)) } catch (_) {} }
174
257
  }
175
258
  }
176
- // «Perfil 1» tanto al migrar como en una instalación nueva: es un nombre que
177
- // el dueño puede cambiar, y evita que la CLI salude con «(sin nombre)».
178
- data.profiles.push({ id, name: 'Perfil 1', createdAt: Date.now() })
179
- data.current = id
180
- save()
181
- return { id, migrated: legacy }
259
+ try {
260
+ // Al migrar, la llave ya existe y esto solo la lee; en una instalación nueva, la crea.
261
+ const pub = await mintKey(dir)
262
+ // «Perfil 1» tanto al migrar como en una instalación nueva: es un nombre que
263
+ // el dueño puede cambiar, y evita que la CLI salude con «(sin nombre)».
264
+ const p = await api.commit(dir, 'Perfil 1', { pub })
265
+ data.current = p.id
266
+ save()
267
+ return { id: p.id, migrated: legacy }
268
+ } catch (e) {
269
+ // En una instalación NUEVA no hay nada que perder y la carpeta de paso se va. En
270
+ // una MIGRACIÓN sí lo hay —los archivos del dueño ya están dentro—, así que se
271
+ // queda donde está y se dice en voz alta: borrarla sería borrar su bóveda.
272
+ if (!legacy) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {} }
273
+ else e.message += ` (your data is safe in ${dir}; it could not be given its final name)`
274
+ throw e
275
+ }
182
276
  },
183
277
 
184
278
  /**
@@ -189,15 +283,110 @@ export function openProfiles (root = dataDir()) {
189
283
  * leería como pisar una cuenta con datos y se rechazaría, que es lo que tiene que
190
284
  * pasar cuando nadie lo pidió.
191
285
  */
192
- add (name, { adopt = false } = {}) {
193
- const id = newId()
194
- ensureDir(dirOf(id))
286
+ /**
287
+ * `kek`: el proveedor de la clave del disco, si el perfil tiene que NACER con él.
288
+ *
289
+ * Se escribe aquí y no después porque ese es justo el momento que importa: el
290
+ * directorio existe y todavía no hay un solo byte dentro. La maestra que genere
291
+ * `open()` un instante más tarde ya nace bajo esa clave y NUNCA existe bajo otra.
292
+ *
293
+ * Migrar un perfil que ya tiene identidad NO da esto, y por eso no se ofrece como
294
+ * si lo diera: su maestra ya se escribió bajo la clave vieja, y una copia del disco
295
+ * anterior a la migración la sigue abriendo para siempre. Un perfil con raíz en el
296
+ * KMS **nace** así (dueño, 2026-08-30).
297
+ */
298
+ stage ({ kek = null, fromEnv = true } = {}) {
299
+ const dir = path.join(root, 'p', stagingName())
300
+ ensureDir(dir)
301
+ // Sin `--kms` explícito, manda el entorno: es lo que permite levantar un contenedor
302
+ // con KMS pasando variables, sin entrar a escribir un JSON dentro. Solo al CREAR:
303
+ // un perfil que ya existe manda con su `atrest.json`.
304
+ kek = kek || (fromEnv ? configFromEnv() : null)
305
+ if (kek) {
306
+ // Comprobar ANTES de dejar rastro: un KMS que no responde no puede dejar a
307
+ // medio crear un perfil cuya maestra no se va a poder volver a abrir.
308
+ try {
309
+ probeKek(dir, kek)
310
+ writeKekConfig(dir, kek)
311
+ } catch (e) {
312
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
313
+ throw e
314
+ }
315
+ }
316
+ return dir
317
+ },
318
+
319
+ /**
320
+ * La carpeta de paso ya tiene su llave: se le pone SU nombre y entra en el registro.
321
+ * El `id` del perfil ES el nombre de su carpeta, y sale de la llave (`keyDirName`).
322
+ */
323
+ async commit (staging, name, { adopt = false, pub } = {}) {
324
+ if (!pub) throw new Error('cannot name the folder: the key is missing')
325
+ const id = await keyDirName(pub)
326
+ const dest = dirOf(id)
327
+ // Esa llave ya tiene carpeta. No es un choque de nombres: es la MISMA llave dos
328
+ // veces, que es justo lo que este esquema existe para que no pase.
329
+ if (fs.existsSync(dest)) {
330
+ try { fs.rmSync(staging, { recursive: true, force: true }) } catch (_) {}
331
+ throw Object.assign(new Error('that key already has a folder here: ' + id), { code: 'key-exists' })
332
+ }
333
+ fs.renameSync(staging, dest)
195
334
  data.profiles.push({ id, name: cleanName(name), createdAt: Date.now(), ...(adopt ? { adopt: true } : {}) })
196
335
  if (!data.current) data.current = id
197
336
  save()
198
337
  return entry(find(id))
199
338
  },
200
339
 
340
+ /**
341
+ * Crea un perfil entero: hace el sitio, deja que `mintKey` acuñe la llave dentro y
342
+ * mueve la carpeta a su nombre. `mintKey(dir)` devuelve la pública, y es cosa de quien
343
+ * llama porque generar una identidad no es asunto del registro.
344
+ */
345
+ async add (name, { adopt = false, kek = null, mintKey } = {}) {
346
+ if (typeof mintKey !== 'function') throw new Error('add() needs mintKey to know whose folder it is')
347
+ const staging = api.stage({ kek })
348
+ try {
349
+ const pub = await mintKey(staging)
350
+ return await api.commit(staging, name, { adopt, pub })
351
+ } catch (e) {
352
+ // Nada a medias: la carpeta de paso se va con el intento fallido.
353
+ try { fs.rmSync(staging, { recursive: true, force: true }) } catch (_) {}
354
+ throw e
355
+ }
356
+ },
357
+
358
+ /**
359
+ * PONERLE A UNA CARPETA VIEJA EL NOMBRE DE SU LLAVE.
360
+ *
361
+ * Es toda la migración: mover la data a la carpeta que le toca. Se hace con la
362
+ * identidad CERRADA —antes de abrirla—, porque la identidad se queda con la ruta como
363
+ * texto y renombrar por debajo la deja escribiendo en una carpeta que ya no existe.
364
+ *
365
+ * De quién es la carpeta se sabe por la marca (`key.json`); si no la tiene —una
366
+ * instalación anterior a que existiera— se abre un momento solo para preguntárselo.
367
+ */
368
+ async ensureNamedByKey (id, mintKey) {
369
+ const dir = dirOf(id)
370
+ if (!fs.existsSync(dir)) return id
371
+ let pub = keyOwnerOf(dir)
372
+ if (!pub && typeof mintKey === 'function') {
373
+ try { pub = await mintKey(dir) } catch (_) { return id }
374
+ }
375
+ if (!pub) return id
376
+ const quiere = await keyDirName(pub)
377
+ if (quiere === id) return id
378
+ if (fs.existsSync(dirOf(quiere))) {
379
+ throw Object.assign(new Error(`cannot rename ${id}: ${quiere} already exists`), { code: 'key-exists' })
380
+ }
381
+ fs.renameSync(dir, dirOf(quiere))
382
+ const p = find(id)
383
+ if (p) p.id = quiere
384
+ if (data.current === id) data.current = quiere
385
+ if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
386
+ save()
387
+ return quiere
388
+ },
389
+
201
390
  /** Quita la marca de «nació para adoptar» (ya adoptó, o se canceló). */
202
391
  clearAdopt (id) {
203
392
  const p = find(id)
@@ -231,7 +420,20 @@ export function openProfiles (root = dataDir()) {
231
420
  // ----- candado -----
232
421
 
233
422
  isProtected: (id) => !!find(id)?.pwd,
234
- isLocked: (id) => { const p = find(id); return !!p?.pwd && !unlocked.has(id) },
423
+ isLocked: (id) => { const p = find(id); return !!p?.pwd && !isOpen(id) },
424
+ /** Cuánto dura abierto el candado sin usarse (ms). 0 = no se cierra solo. */
425
+ get autoLockMs () { return autoLockMs },
426
+ /**
427
+ * «Se acaba de usar»: estira el plazo del bloqueo automático. Lo llama la consola
428
+ * al atender CADA petición suya, y solo eso cuenta como uso: que un aparato pida su
429
+ * configuración no abre nada ni alarga nada, porque el candado no es suyo.
430
+ * Devuelve si el perfil estaba abierto (a uno cerrado no hay nada que estirarle).
431
+ */
432
+ touch (id) {
433
+ if (!isOpen(id)) return false
434
+ open(id)
435
+ return true
436
+ },
235
437
  assertUnlocked (id) {
236
438
  if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
237
439
  },
@@ -258,7 +460,7 @@ export function openProfiles (root = dataDir()) {
258
460
 
259
461
  async unlock (id, password) {
260
462
  const p = assertExists(id)
261
- if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
463
+ if (!p.pwd) { open(id); return { ok: true, locked: false } }
262
464
  // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
263
465
  // fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
264
466
  // Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
@@ -296,7 +498,7 @@ export function openProfiles (root = dataDir()) {
296
498
  }
297
499
  delete p.tries
298
500
  save()
299
- unlocked.add(id)
501
+ open(id)
300
502
  return { ok: true, locked: false }
301
503
  },
302
504
 
@@ -314,7 +516,7 @@ export function openProfiles (root = dataDir()) {
314
516
  p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
315
517
  delete p.tries
316
518
  save()
317
- unlocked.add(id)
519
+ open(id)
318
520
  return entry(p)
319
521
  },
320
522
 
@@ -324,7 +526,7 @@ export function openProfiles (root = dataDir()) {
324
526
  delete p.pwd
325
527
  delete p.tries
326
528
  save()
327
- unlocked.add(id)
529
+ open(id)
328
530
  return entry(p)
329
531
  }
330
532
  }
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',