@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 +14 -7
- package/package.json +1 -1
- package/src/ctl.js +9 -3
- package/src/daemon.js +37 -8
- package/src/manager.js +8 -2
- package/src/profiles.js +66 -9
- package/src/tui/app.js +77 -2
- package/src/tui/i18n.js +2 -0
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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.
|
|
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
|
-
|
|
1145
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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 && !
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|