@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/README.md +14 -7
- package/bin/dotrino-vaultd.js +27 -1
- package/lib/src/atrest.js +116 -2
- package/lib/src/kek.js +293 -0
- package/lib/src/lock.js +117 -0
- package/lib/src/protocol.js +9 -1
- package/lib/src/service.js +44 -5
- package/package.json +4 -4
- package/src/ctl.js +311 -14
- package/src/daemon.js +204 -22
- package/src/keyowner.js +83 -0
- package/src/manager.js +41 -6
- package/src/passwords.js +15 -4
- package/src/profiles.js +227 -25
- package/src/tui/app.js +77 -2
- package/src/tui/i18n.js +2 -0
- package/src/vault.js +137 -19
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 && !
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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',
|