@dotrino/vaultd 0.26.2 → 0.38.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/daemon.js CHANGED
@@ -36,7 +36,7 @@ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
36
36
  * pid muerto (se cortó la luz) no estorba. Con `DOTRINO_VAULT_DIR` distintos conviven
37
37
  * cuantas quieras: lo que colisiona es el directorio, no el programa.
38
38
  */
39
- function comprobarInstanciaUnica (dir) {
39
+ function assertSingleInstance (dir) {
40
40
  let s = null
41
41
  try { s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')) } catch (_) { return }
42
42
  const pid = Number(s?.pid)
@@ -52,7 +52,7 @@ function comprobarInstanciaUnica (dir) {
52
52
  export async function runDaemon () {
53
53
  const dir = dataDir()
54
54
  const proxyUrl = process.env.PROXY_URL || 'wss://proxy.dotrino.com'
55
- comprobarInstanciaUnica(dir)
55
+ assertSingleInstance(dir)
56
56
 
57
57
  const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
58
58
  // Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
@@ -79,26 +79,47 @@ export async function runDaemon () {
79
79
  }
80
80
  writeState()
81
81
  const profilesFile = path.join(dir, 'profiles-list.json')
82
- const dumpProfiles = (extra = {}) => { writeState(); writeJson(profilesFile, { v: 1, at: Date.now(), current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
82
+ // `req: null` VA SIEMPRE, aunque nadie haya pedido nada: el daemon vuelca esta lista
83
+ // también por su cuenta (cada repaso, y al atender cualquier otra cosa), y quien espera
84
+ // respuesta tiene que poder distinguir «este volcado no contesta a nadie» de «este
85
+ // contesta a lo mío». Sin esa marca, un repaso que caía en medio pasaba por respuesta:
86
+ // el `unlock` se daba por hecho con la foto de cuando aún estaba cerrada, y un
87
+ // «contraseña incorrecta» se perdía por el camino sin que nadie lo llegara a ver.
88
+ const dumpProfiles = (extra = {}) => { writeState(); writeJson(profilesFile, { v: 1, at: Date.now(), req: null, current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
83
89
 
84
90
  console.log(`dotrino-vault · datos en ${dir} · proxy ${proxyUrl}`)
85
91
  for (const p of mgr.summary()) {
86
92
  console.log(`perfil ${p.current ? '*' : ' '} ${p.name || '(sin nombre)'} · ${p.id} · ${p.fingerprint}${p.protected ? (p.locked ? ' · 🔒 bloqueado' : ' · 🔓 desbloqueado') : ''}`)
87
93
  }
88
94
 
89
- /** Perfil destino de una petición de la CLI (o el activo si no lo dice). */
95
+ /**
96
+ * Perfil destino de una petición de la CLI (o el activo si no lo dice), con su CANDADO.
97
+ *
98
+ * EL CANDADO ES DE ESTA CONSOLA. Un perfil con contraseña y bloqueado no se puede ver ni
99
+ * tocar desde la máquina de la bóveda —ni la lista de aparatos, ni las variables, ni tus
100
+ * datos, ni emparejar o quitar nada— hasta que alguien teclee la contraseña
101
+ * (`dotrino-vault unlock`). Lo que NO cambia es el servicio: los aparatos ya emparejados
102
+ * siguen firmando, leyendo y guardando, porque la bóveda es de ellos tanto como de esta
103
+ * pantalla. Lo que se protege es la consola, que es donde se administra y donde se mira.
104
+ */
90
105
  const resolveTarget = (req) => {
91
106
  try {
92
107
  const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
93
- return { id, vault: mgr.get(id) }
108
+ return { id, vault: mgr.get(id), locked: mgr.profiles.isLocked(id) }
94
109
  } catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
95
110
  }
96
- const targetOf = (req) => resolveTarget(req)?.vault || null
111
+ /** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
112
+ const targetOf = (req) => {
113
+ const t = resolveTarget(req)
114
+ if (!t) return null
115
+ if (t.locked) { console.error('[vault] profile %s is locked: request refused (unlock it to use this console)', t.id); return null }
116
+ return t.vault
117
+ }
97
118
 
98
119
  // --- SIGUSR1: iniciar emparejamiento ---
99
120
  const pairFile = path.join(dir, 'pair.json')
100
121
  const pairReqFile = path.join(dir, 'pair-request.json')
101
- async function atenderEmparejamiento () {
122
+ async function handlePairingRequest () {
102
123
  try {
103
124
  rm(pendingEnrollFile)
104
125
  // Pairing manual por CLI = gesto explícito del dueño → cert de identidad completo.
@@ -111,12 +132,19 @@ export async function runDaemon () {
111
132
  // vault:secrets:<ns> — para enrolar un SERVICIO (proxy, geo…) que lee sus
112
133
  // secretos, sin poder firmar como el usuario ni leer sus datos.
113
134
  const pairReq = readJsonSafe(pairReqFile); rm(pairReqFile)
135
+ const locked = resolveTarget(pairReq)
136
+ if (locked?.locked) {
137
+ // Se responde por el MISMO archivo que espera quien pidió el QR: si no, se queda
138
+ // mirando una pantalla vacía hasta que se agote el tiempo, sin saber por qué.
139
+ writeJson(pairFile, { v: 2, at: Date.now(), profile: locked.id, locked: true })
140
+ return console.error('[vault] profile %s is locked: pairing refused', locked.id)
141
+ }
114
142
  const vault = targetOf(pairReq)
115
143
  if (!vault) return
116
144
  const profileId = pairReq?.profile ? mgr.resolve(pairReq.profile) : mgr.currentId()
117
145
  const isService = typeof pairReq?.service === 'string' && pairReq.service
118
146
  const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
119
- const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
147
+ const label = pairReq?.label || (isService ? 'service:' + pairReq.service : 'cli')
120
148
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
121
149
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
122
150
  // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
@@ -131,7 +159,7 @@ export async function runDaemon () {
131
159
  // crea uno vacío), o no habría dónde meterla.
132
160
  const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
133
161
  const { qr, expiresInMs } = await vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
134
- writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
162
+ writeJson(pairFile, { v: 2, at: Date.now(), qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
135
163
  // El token es un secreto efímero: no debe quedar en disco más allá de su
136
164
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
137
165
  const tok = qr.token
@@ -148,6 +176,7 @@ export async function runDaemon () {
148
176
  // --- SIGUSR2: approve / reject / revoke / secretos / perfiles + volcados ---
149
177
  const devFile = path.join(dir, 'devices.json')
150
178
  const approveReqFile = path.join(dir, 'approve-request.json')
179
+ const approveFile = path.join(dir, 'approve.json')
151
180
  const rejectReqFile = path.join(dir, 'reject-request.json')
152
181
  const revokeReqFile = path.join(dir, 'revoke-request.json')
153
182
  const secretReqFile = path.join(dir, 'secret-request.json')
@@ -189,15 +218,27 @@ export async function runDaemon () {
189
218
  }
190
219
  }
191
220
 
192
- async function atenderPeticiones () {
221
+ async function handleRequests () {
193
222
  try {
194
223
  const appr = readJsonSafe(approveReqFile)
195
224
  if (appr?.code) {
225
+ rm(approveReqFile)
226
+ // EL RESULTADO SE CONTESTA, no solo se anota en el log del servicio. Un código
227
+ // equivocado NO emite certificado (lo corta `enroll.js`), pero eso se quedaba en
228
+ // esta consola: quien aprobaba desde la TUI leía «Dispositivo aprobado», el
229
+ // pendiente desaparecía de la pantalla y el aparato seguía esperando al otro lado
230
+ // sin que nadie pudiera reintentar.
231
+ const answer = (extra) => writeJson(approveFile, { v: 1, at: Date.now(), req: appr.id || null, ...extra })
196
232
  try {
197
233
  const vault = targetOf(appr)
198
- const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile); console.log('[vault] aprobado %s', r.deviceId)
199
- } catch (e) { console.error('[vault] approval failed:', e.message) }
200
- rm(approveReqFile)
234
+ if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
235
+ const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
236
+ console.log('[vault] aprobado %s', r.deviceId)
237
+ answer({ ok: true, deviceId: r.deviceId || null })
238
+ } catch (e) {
239
+ console.error('[vault] approval failed:', e.message)
240
+ answer({ ok: false, error: e.message, code: e.code || 'APPROVE_FAILED' })
241
+ }
201
242
  }
202
243
  const rej = readJsonSafe(rejectReqFile)
203
244
  if (rej?.deviceId) {
@@ -232,53 +273,128 @@ export async function runDaemon () {
232
273
  } catch (e) { console.error('[vault] revocation failed:', e.message) }
233
274
  rm(revokeReqFile)
234
275
  }
235
- // Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
236
- // vive un instante en el mismo dir 0700 del vault y se borra al consumir.
276
+ // Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
277
+ // del CLI o de la TUI. El archivo con el valor vive un instante en el mismo dir
278
+ // 0700 del vault y se borra al consumir.
237
279
  const sec = readJsonSafe(secretReqFile)
238
280
  if (sec?.op) {
239
281
  rm(secretReqFile)
240
282
  try {
241
283
  const vault = targetOf(sec)
242
- if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
284
+ // Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
285
+ // variables entran de una vez y sale UN solo aviso de cambio, para que el
286
+ // servicio no se reinicie a media carga y arranque con la mitad puesta.
287
+ if (sec.op === 'batch') {
288
+ const changed = sec.pub
289
+ ? await vault.applyDeviceSecrets(sec.pub, sec.items)
290
+ : vault.applySecrets(sec.ns, sec.items)
291
+ console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
292
+ } else if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
243
293
  else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
294
+ else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
295
+ else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
296
+ // Visibilidad: si el valor puede salir hacia la consola remota. No toca el valor.
297
+ else if (sec.op === 'vis') { vault.setSecretVisibility(sec.ns, sec.key, sec.public); console.log('[vault] secret visibility: %s/%s → %s', sec.ns, sec.key, sec.public ? 'public' : 'private') }
298
+ else if (sec.op === 'dev-vis') { await vault.setDeviceSecretVisibility(sec.pub, sec.key, sec.public); console.log('[vault] device secret visibility: %s → %s', sec.key, sec.public ? 'public' : 'private') }
244
299
  } catch (e) { console.error('[vault] secret failed:', e.message) }
245
300
  }
246
301
  // Perfiles / candado.
247
302
  const preq = readJsonSafe(profileReqFile)
248
303
  if (preq?.op) {
249
304
  rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
250
- let extra = {}
251
- try { extra = await handleProfileRequest(preq) }
305
+ // `req`: el id de la petición viaja de vuelta en el volcado. Sin él, quien
306
+ // espera podía quedarse con un volcado anterior —el daemon los escribe también
307
+ // por su cuenta— y dar por contestado lo que aún no se había hecho.
308
+ let extra = { req: preq.id || null }
309
+ try { extra = { ...extra, ...(await handleProfileRequest(preq)) } }
252
310
  // `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
253
311
  // que leerse en el idioma de quien lo lee, no en el del daemon).
254
- catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] profile: %s', e.message) }
312
+ catch (e) {
313
+ // `waitSec`/`tries` viajan con el error: quien lo enseña necesita el dato, no
314
+ // solo el motivo («espera 32 s» y «van 9 intentos» son lo que cambia la conducta).
315
+ extra = { req: preq.id || null, error: e.message, ...(e.code ? { code: e.code } : {}), ...(e.waitSec ? { waitSec: e.waitSec } : {}), ...(e.tries ? { tries: e.tries } : {}) }
316
+ console.error('[vault] profile: %s', e.message)
317
+ }
255
318
  dumpProfiles(extra)
256
319
  } else {
257
- dumpProfiles()
320
+ // NADIE PREGUNTÓ: se refresca el estado, pero NO se pisa `profiles-list.json`.
321
+ // Ese archivo es la RESPUESTA a una petición, y el daemon repasa su carpeta cada
322
+ // dos segundos: al volcarlo también sin que nadie lo pidiera, un repaso que caía
323
+ // entre la respuesta y quien la esperaba se la llevaba por delante. Eso es lo que
324
+ // hacía que un `unlock` correcto contestara «sigue cerrada» y que un «contraseña
325
+ // incorrecta» se perdiera sin llegar a verse. Quien lee esta lista —la TUI y el
326
+ // CLI— siempre pide antes, así que no se queda sin ella.
327
+ writeState()
328
+ }
329
+ // Volcados que lee la CLI (`devices`, `members`, `secret list`) y la TUI. A QUÉ
330
+ // perfil miran lo dice dump-request.json; sin él, al activo.
331
+ //
332
+ // SOLO SI ALGUIEN LOS PIDIÓ. Estos archivos son la RESPUESTA a una petición, igual
333
+ // que `profiles-list.json` (ver arriba), y el daemon pasa por aquí cada dos segundos
334
+ // —y otra vez, en el acto, si algo llegó mientras atendía—. Volcarlos también sin que
335
+ // nadie preguntara se llevaba por delante la respuesta recién escrita: quien esperaba
336
+ // veía un volcado con `req: null` en vez del suyo, seguía esperando y a los seis
337
+ // segundos se rendía. Eso era la TUI colgada en «Cargando dispositivos…» y luego «el
338
+ // daemon no responde» — con el daemon sano y contestando en milisegundos.
339
+ // NO SE BORRA LO QUE NO SE PUDO LEER. `readJsonSafe` devuelve null tanto si el
340
+ // archivo no está como si llegó a medias (`fs.watch` avisa al crearlo, no al
341
+ // terminar de escribirlo), y borrarlo en ese segundo caso destruía la petición: el
342
+ // que la había pedido esperaba seis segundos y leía «el daemon no respondió», con el
343
+ // daemon sano y contestando lo demás. Si no parsea se deja donde está y lo recoge el
344
+ // repaso de 2 s, que es la misma regla que ya tenía la petición de emparejamiento.
345
+ const dumpReq = readJsonSafe(dumpReqFile)
346
+ if (dumpReq || !fs.existsSync(dumpReqFile)) rm(dumpReqFile)
347
+ const meReq = readJsonSafe(meReqFile)
348
+ if (!dumpReq && !meReq) return
349
+ const t = resolveTarget(dumpReq || meReq || appr || rej || req || sec || {}) || { id: mgr.currentId(), vault: mgr.current(), locked: false }
350
+ // El id de la petición vuelve en cada volcado, para que quien espera sepa que le
351
+ // contestan a ÉL y no lea el volcado de la vuelta anterior (ver `waitFor`).
352
+ const reqId = dumpReq?.id || null
353
+ if (t.locked) {
354
+ // BLOQUEADO: se contesta que lo está, y nada más. Los volcados se escriben igual
355
+ // (quien pregunta espera una respuesta, no un plantón) pero VACÍOS: ni aparatos, ni
356
+ // nombres de variables, ni acta. Antes el candado no tapaba ninguna de las tres.
357
+ const closed = { v: 1, at: Date.now(), req: reqId, profile: t.id, locked: true }
358
+ if (dumpReq) {
359
+ writeJson(secretsListFile, { ...closed, ns: {}, dev: [] })
360
+ writeJson(devFile, { ...closed, issued: [], revoked: [] })
361
+ writeJson(path.join(dir, 'acta.json'), { ...closed, members: [] })
362
+ }
363
+ rm(meReqFile)
364
+ if (meReq) writeJson(meFile, { ...closed, req: meReq.id || null, me: null })
365
+ return
366
+ }
367
+ if (dumpReq) {
368
+ // Los DOS cajones: `ns` (por scope, que comparten todos los aparatos del perfil) y
369
+ // `dev` (las propias de cada aparato). Con el VALOR de las públicas —que es lo que
370
+ // pública significa— y sin el de las privadas, que no salen del proceso.
371
+ //
372
+ // Ese valor queda escrito en claro en este archivo (0600, y quien lo lee lo borra
373
+ // en cuanto lo tiene). Es material que su dueño marcó como mostrable, y aquí ya
374
+ // viaja a un navegador; lo que NO puede pasar es que se quede en el disco esperando
375
+ // a que alguien copie la carpeta, porque eso sí burlaría el cifrado en reposo.
376
+ writeJson(secretsListFile, {
377
+ v: 2, at: Date.now(), req: reqId, profile: t.id,
378
+ ns: t.vault.listSecrets(),
379
+ dev: await t.vault.listDeviceSecrets()
380
+ })
381
+ writeJson(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
382
+ // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
383
+ try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
258
384
  }
259
- // Volcados que lee la CLI (`devices`, `secret list`). A QUÉ perfil miran lo
260
- // dice dump-request.json; sin él, al activo.
261
- const dumpReq = readJsonSafe(dumpReqFile); rm(dumpReqFile)
262
- const t = resolveTarget(dumpReq || appr || rej || req || sec || {}) || { id: mgr.currentId(), vault: mgr.current() }
263
- // Nombres de secretos, nunca valores.
264
- writeJson(secretsListFile, { v: 1, at: Date.now(), profile: t.id, ns: t.vault.listSecrets() })
265
- writeJson(devFile, { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.listDevices()) })
266
- // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
267
- try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
268
385
 
269
386
  // PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
270
387
  // Solo se vuelca cuando se PIDE, no en cada señal: es contenido del usuario y no tiene
271
388
  // por qué quedar escrito en un archivo suelto cada vez que alguien mira los miembros.
272
389
  // La FOTO no entra en el volcado (son hasta ~90 KB de data-URI que nadie va a leer en
273
390
  // una terminal): solo se dice que la hay, de qué tipo y cuánto pesa.
274
- const meReq = readJsonSafe(meReqFile)
275
391
  if (meReq) {
276
392
  rm(meReqFile)
277
393
  try {
278
394
  const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
279
395
  const { me } = tm.vault.threads.methods.profileGet()
280
- const { avatar, ...resto } = me || {}
281
- writeJson(meFile, { v: 1, at: Date.now(), profile: tm.id, me: me ? { ...resto, avatar: avatarInfo(avatar) } : null })
396
+ const { avatar, ...rest } = me || {}
397
+ writeJson(meFile, { v: 1, at: Date.now(), req: meReq.id || null, profile: tm.id, me: me ? { ...rest, avatar: avatarInfo(avatar) } : null })
282
398
  } catch (e) { console.error('[vault] could not dump the profile:', e.message) }
283
399
  }
284
400
  } catch (e) {
@@ -298,38 +414,44 @@ export async function runDaemon () {
298
414
  // sistemas, más un repaso periódico por si el watcher se pierde un evento (pasa en
299
415
  // carpetas de red y en algunos montajes). Las señales se mantienen donde existen: no
300
416
  // estorban y hacen que la respuesta sea inmediata.
301
- const REPASO_MS = 2000
302
- let atendiendo = false
303
- async function atender () {
304
- if (atendiendo) return // una a la vez: las peticiones se consumen y se borran
305
- atendiendo = true
417
+ const SWEEP_MS = 2000
418
+ let serving = false
419
+ let anotherRound = false
420
+ async function serve () {
421
+ // Una a la vez (las peticiones se consumen y se borran), pero lo que llegue mientras
422
+ // tanto NO se pierde: se anota y se da otra vuelta al terminar. Antes se descartaba,
423
+ // y la petición se quedaba esperando al repaso de 2 s — tiempo de sobra para que
424
+ // quien pidió escribiera la siguiente encima.
425
+ if (serving) { anotherRound = true; return }
426
+ serving = true
306
427
  try {
307
428
  // OJO: `fs.watch` avisa al CREAR el archivo, antes de que el CLI termine de
308
429
  // escribirlo. Si se lee a medias, el JSON no parsea y la petición se pierde con
309
430
  // sus datos — y el emparejamiento salía como si fuera de un dispositivo normal,
310
431
  // sin el `--service`, emitiendo un cert con el scope equivocado. Así que solo se
311
432
  // atiende cuando el archivo YA parsea; si no, lo recoge el repaso de 2 s.
312
- if (readJsonSafe(pairReqFile)) await atenderEmparejamiento()
313
- await atenderPeticiones()
433
+ if (readJsonSafe(pairReqFile)) await handlePairingRequest()
434
+ await handleRequests()
314
435
  } catch (e) { console.error('[vault] error serving a request:', e.message) }
315
- finally { atendiendo = false }
436
+ finally { serving = false }
437
+ if (anotherRound) { anotherRound = false; await serve() }
316
438
  }
317
439
 
318
440
  try {
319
- fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) atender() })
441
+ fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) serve() })
320
442
  } catch (e) {
321
443
  console.error('[vault] could not watch %s (%s); will be served by polling only', dir, e.message)
322
444
  }
323
- const repaso = setInterval(atender, REPASO_MS)
324
- repaso.unref?.()
445
+ const sweep = setInterval(serve, SWEEP_MS)
446
+ sweep.unref?.()
325
447
 
326
448
  // POSIX: la señal sigue valiendo como atajo inmediato. En Windows no existe y no pasa nada.
327
449
  // Van por `atender()` y NO llaman directo: si no, la señal y el vigilante corren a la
328
450
  // vez, la primera consume la petición y la segunda la lee vacía — y un `pair --service`
329
451
  // acababa emitiendo un cert de dispositivo normal, con el scope equivocado.
330
452
  if (process.platform !== 'win32') {
331
- process.on('SIGUSR1', () => { atender() })
332
- process.on('SIGUSR2', () => { atender() })
453
+ process.on('SIGUSR1', () => { serve() })
454
+ process.on('SIGUSR2', () => { serve() })
333
455
  }
334
456
 
335
457
  // --- apagado limpio ---
package/src/manager.js CHANGED
@@ -24,11 +24,11 @@ import { dataDir, ensureDir } from './paths.js'
24
24
  */
25
25
  export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
26
26
  if (!isMaster || memberCount <= 1) return true
27
- const otros = memberCount - 1
27
+ const others = memberCount - 1
28
28
  const e = new Error(
29
- `la cuenta "${name}" la manda esta bóveda y tiene ${otros} dispositivo(s) más: ` +
30
- 'pásale primero el mando a uno que esté conectado. Si la borras así, se quedan ' +
31
- 'con su llave y sin nadie que pueda volver a firmar el acta.'
29
+ `this vault is the master of account "${name}" and it has ${others} more device(s): ` +
30
+ 'hand the master over to one that is connected first. Deleting it like this leaves them ' +
31
+ 'with their key and nobody able to sign the record again.'
32
32
  )
33
33
  e.code = 'MASTER_WITH_MEMBERS'
34
34
  e.members = memberCount
@@ -114,11 +114,11 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
114
114
  // se lleva su llave y su copia, y la cuenta sigue viva donde vive el master.)
115
115
  const v = running.get(id)
116
116
  if (v) {
117
- const [soyMaster, acta] = await Promise.all([
117
+ const [isMaster, record] = await Promise.all([
118
118
  v.isMaster().catch(() => false),
119
119
  v.profileMembers().catch(() => ({ members: [] }))
120
120
  ])
121
- assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
121
+ assertCanRemove({ isMaster, memberCount: (record?.members || []).length, name: profiles.get(id)?.name || id })
122
122
  } else {
123
123
  log('[vault] profile %s is not open: deleting without being able to check its record', id)
124
124
  }
package/src/profiles.js CHANGED
@@ -28,6 +28,15 @@ const REGISTRY = 'profiles.json'
28
28
  const PWD_ITER = 300000 // mismo coste que el candado del navegador
29
29
  const MAX_NAME = 40
30
30
 
31
+ /**
32
+ * Cuánto tarda el freno en OLVIDAR los fallos. Sin esto la cuenta solo subía —solo la
33
+ * borraba un acierto—, así que teclear mal la contraseña cinco veces un martes te dejaba
34
+ * el vault con esperas de minutos el miércoles, y cada intento nuevo (aunque fuera el
35
+ * bueno) la alargaba sin llegar a comprobarse: la bóveda quedaba cerrada para su dueño.
36
+ * El freno tiene que estorbar a una RÁFAGA, no a quien vuelve al día siguiente.
37
+ */
38
+ const TRIES_FORGET_MS = 15 * 60 * 1000
39
+
31
40
  /**
32
41
  * Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
33
42
  *
@@ -97,7 +106,7 @@ export function openProfiles (root = dataDir()) {
97
106
  const needle = String(ref).trim().toLowerCase()
98
107
  const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
99
108
  if (hits.length === 1) return hits[0].id
100
- if (hits.length > 1) throw new Error(`hay ${hits.length} perfiles llamados "${ref}"; usa su id (dotrino-vault profile ls)`)
109
+ if (hits.length > 1) throw new Error(`there are ${hits.length} profiles named "${ref}"; use its id (dotrino-vault profile ls)`)
101
110
  throw new Error('profile does not exist: ' + ref)
102
111
  },
103
112
 
@@ -191,15 +200,29 @@ export function openProfiles (root = dataDir()) {
191
200
  if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
192
201
  // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
193
202
  // fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
194
- const tries = p.tries || { n: 0, at: 0 }
203
+ // Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
204
+ // el rato, se empieza de cero. Así el freno sigue frenando una ráfaga —los intentos
205
+ // seguidos se cuentan igual— pero no convierte un despiste de ayer en un vault que
206
+ // ya no se abre.
207
+ let tries = p.tries || { n: 0, at: 0 }
208
+ if (tries.at && Date.now() - tries.at > TRIES_FORGET_MS) {
209
+ tries = { n: 0, at: 0 }
210
+ if (p.tries) { delete p.tries; save() }
211
+ }
195
212
  const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
196
213
  const left = tries.at + waitMs - Date.now()
197
- if (left > 0) throw new Error(`demasiados intentos: espera ${Math.ceil(left / 1000)} s`)
214
+ // Con CÓDIGO: la TUI es bilingüe y lo traduce, y la CLI puede decirlo con sus
215
+ // palabras. Sin código, el rechazo llegaba como un texto suelto del daemon y era
216
+ // indistinguible de «se volvió a pedir la contraseña porque sí».
217
+ if (left > 0) {
218
+ throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
219
+ { code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
220
+ }
198
221
  const proof = await derivePwd(password, p.pwd.salt, p.pwd.iter)
199
222
  if (proof !== p.pwd.verifier) {
200
223
  p.tries = { n: tries.n + 1, at: Date.now() }
201
224
  save()
202
- throw new Error('wrong password')
225
+ throw Object.assign(new Error('wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
203
226
  }
204
227
  delete p.tries
205
228
  save()