@dotrino/vaultd 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -456,18 +456,25 @@ y así un reinicio del PC nunca deja tus apps muertas esperando a que alguien te
456
456
  ```sh
457
457
  dotrino-vault profile password # pone o cambia la contraseña (te la pregunta)
458
458
  dotrino-vault profile password rm # la quita
459
- dotrino-vault unlock # abre la bóveda en esta consola
460
- dotrino-vault lock # vuelve a cerrarla
459
+ dotrino-vault unlock # abre la bóveda en esta consola (5 min sin usarse y se cierra sola)
460
+ dotrino-vault lock # vuelve a cerrarla ya
461
461
  ```
462
462
 
463
463
  Lo único que se sigue viendo con el candado puesto es que **existe** y que está cerrada
464
464
  (`status`, `profile ls`): si no, no habría forma de saber qué abrir.
465
465
 
466
- El perfil se vuelve a bloquear al reiniciar el servicio. La contraseña **no se
467
- guarda**: solo un verificador con sal (PBKDF2), igual que el candado del navegador.
468
- Tiene un mínimo de 4 caracteres y, tras 5 intentos fallidos, cada intento nuevo
469
- espera cada vez más (hasta 5 minutos); la cuenta de fallos se guarda, así que
470
- reiniciar no la borra.
466
+ **Se vuelve a cerrar solo a los 5 minutos sin usarse**, además de al reiniciar el
467
+ servicio o con `dotrino-vault lock`. El plazo se cuenta desde la última cosa que hiciste
468
+ en la consola, no desde que la abriste: mientras trabajas no te echa, y en cuanto te
469
+ levantas de la silla se cierra. Lo que piden tus aparatos por el proxy **no** cuenta como
470
+ uso —el candado no es suyo—, así que siguen funcionando igual mientras la consola se
471
+ cierra. La TUI, además, **olvida la contraseña** en ese momento: si no, la reabriría sola
472
+ a la siguiente tecla.
473
+
474
+ La contraseña **no se guarda**: solo un verificador con sal (scrypt), igual que el
475
+ candado del navegador. Tiene un mínimo de 12 caracteres —varias palabras al azar— y,
476
+ tras 5 intentos fallidos, cada intento nuevo espera cada vez más (hasta 5 minutos); la
477
+ cuenta de fallos se guarda, así que reiniciar no la borra.
471
478
 
472
479
  Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha: cualquier
473
480
  comando que mire o toque esa bóveda falla con «perfil bloqueado» y hay que correr
package/lib/src/enroll.js CHANGED
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
56
56
  export const scopeToCaps = (scope) =>
57
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
58
 
@@ -98,7 +98,11 @@ export const SCOPE = Object.freeze({
98
98
  // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
99
99
  // de master y sigue siendo local. No se empareja — se concede desde el PC.
100
100
  ADMIN: 'vault:admin',
101
- APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
101
+ APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
102
+ // El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
103
+ // Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
104
+ // lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
105
+ PASSWORDS: 'vault:passwords'
102
106
  })
103
107
 
104
108
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.51.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": {
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.60.3",
23
+ "@dotrino/identity": "^0.61.0",
24
24
  "@dotrino/passmanager": "^0.1.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -195,16 +195,19 @@ async function cmdPair (args = []) {
195
195
  if (scIdx >= 0) {
196
196
  const raw = args[scIdx + 1]
197
197
  if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
198
- const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
198
+ const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store', contrasenas: 'passwords', 'contraseñas': 'passwords' }
199
199
  scope = []
200
200
  for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
201
201
  const t = ALIAS[tok] || tok
202
202
  if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
203
203
  if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
204
204
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
205
+ // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
206
+ // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
207
+ if (t === 'passwords') { scope.push('vault:passwords'); continue }
205
208
  const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
206
209
  if (m) { scope.push('vault:secrets:' + m[1]); continue }
207
- console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
210
+ console.error('permiso desconocido: %s (sign | read | store | contrasenas | secrets:<ns>)', tok); process.exit(2)
208
211
  }
209
212
  if (service) scope.push('vault:secrets:' + service)
210
213
  scope = [...new Set(scope)]
@@ -404,7 +407,7 @@ async function cmdMembers () {
404
407
  // se mira quién es quién — y en la lista de variables ya seria tarde.
405
408
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
406
409
  }
407
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +permiso')
410
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
408
411
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
409
412
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
410
413
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
@@ -463,42 +466,20 @@ async function cmdApproval (args = []) {
463
466
  : `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
464
467
  }
465
468
 
466
- /**
467
- * `dotrino-vault passwords <ID> on|off` — deja (o no) que ese aparato pida contraseñas
468
- * de tu bóveda.
469
- *
470
- * Es una lista de ESTA bóveda, no del acta, por lo mismo que `approval`: es ella la que
471
- * entrega. El acta manda igual — si revocas el aparato allí, deja de pedir aunque siga
472
- * en esta lista. Y si además le pones `approval on`, cada arranque suyo te pedirá el
473
- * visto bueno en el teléfono.
474
- */
475
- async function cmdPasswords (args = []) {
476
- const [id, val] = args
477
- if (!id || !['on', 'off'].includes(val)) {
478
- console.error('uso: dotrino-vault passwords <ID> on|off')
479
- process.exit(1)
480
- }
481
- const m = await findMember(id)
482
- writeReq('secret-request.json', { op: 'passwords', pub: m.pub, id: m.id, label: m.label || '', on: val === 'on' })
483
- sendSignal(requireDaemon().pid, 'SIGUSR2')
484
- if (val === 'on') {
485
- console.log(`Listo: ${m.id} puede pedir contraseñas de tu bóveda (de una en una; nunca la lista entera).`)
486
- console.log('Reinicia el vault: al arrancar imprime el código de enlace que se pega en la extensión.')
487
- } else {
488
- console.log(`Listo: ${m.id} deja de poder pedir contraseñas.`)
489
- }
490
- }
491
-
492
469
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
493
470
  async function cmdCaps (args = []) {
494
471
  const [id, ...changes] = args
495
472
  if (!id || !changes.length) {
496
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+permiso|-permiso')
473
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
497
474
  process.exit(2)
498
475
  }
499
476
  const CAP_BY_WORD = {
500
477
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
501
- sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve'
478
+ // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
479
+ // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
480
+ contrasenas: 'passwords', 'contraseñas': 'passwords',
481
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
482
+ passwords: 'passwords'
502
483
  }
503
484
  const s = requireDaemon()
504
485
  const m = await findMember(id)
@@ -1160,8 +1141,13 @@ function askText (prompt) {
1160
1141
 
1161
1142
  async function cmdUnlock () {
1162
1143
  const pwd = await askPassword('Contraseña del perfil: ')
1163
- reportProfiles(await profileRequest('unlock', { password: pwd }))
1164
- console.log('Ya puedes editar el perfil. Se vuelve a bloquear al reiniciar el servicio (o con: dotrino-vault lock).')
1144
+ // El daemon devuelve cuánto aguanta abierto: se dice AQUÍ, al abrirlo, que es cuando
1145
+ // sirve de algo. Encontrárselo cerrado sin haberlo leído nunca parece una avería.
1146
+ const out = await profileRequest('unlock', { password: pwd })
1147
+ reportProfiles(out)
1148
+ const min = Math.max(1, Math.round((out?.autoLockMs || 5 * 60 * 1000) / 60000))
1149
+ console.log(`Ya puedes editar el perfil. Se vuelve a bloquear solo tras ${min} min sin usarse` +
1150
+ ' (o al reiniciar el servicio, o con: dotrino-vault lock).')
1165
1151
  }
1166
1152
 
1167
1153
  async function cmdLock () {
@@ -1191,7 +1177,7 @@ function help () {
1191
1177
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1192
1178
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1193
1179
  pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1194
- pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
1180
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1195
1181
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1196
1182
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
1197
1183
  aparatos del perfil que sirven ese namespace
@@ -1206,10 +1192,6 @@ function help () {
1206
1192
  secret device set <ID> CLAVE=valor …
1207
1193
  secret device import <ID> [archivo.env]
1208
1194
  secret device rm <ID> <CLAVE> borra una variable de ese aparato
1209
- passwords <ID> on|off deja que ese aparato pida CONTRASEÑAS de tu bóveda
1210
- (de una en una; nunca la lista entera). Con
1211
- approval on además te pedirá el visto bueno en el
1212
- teléfono. Reinicia el vault tras el primer on
1213
1195
  secret list lista los dos cajones: el valor de las públicas,
1214
1196
  tapadas las privadas
1215
1197
  secret show [device] <ns|ID> <CLAVE>
@@ -1234,7 +1216,7 @@ function help () {
1234
1216
  me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
1235
1217
  members el acta del perfil: quién es tuyo y qué puede hacer
1236
1218
  label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
1237
- caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
1219
+ caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
1238
1220
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1239
1221
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1240
1222
  logs últimos logs del servicio
@@ -1254,7 +1236,8 @@ dispositivos siguen firmando y leyendo aunque esté bloqueado):
1254
1236
  profile password [set] pone o cambia la contraseña
1255
1237
  profile password rm la quita
1256
1238
  unlock desbloquea para poder editar
1257
- lock vuelve a bloquear (también al reiniciar el servicio)
1239
+ lock vuelve a bloquear (también solo, a los 5 min sin
1240
+ usarse, y al reiniciar el servicio)
1258
1241
 
1259
1242
  Arrancar y parar, según dónde corra:
1260
1243
  Linux (servicio) systemctl --user {start,stop,restart} dotrino-vault
@@ -1284,7 +1267,6 @@ export async function runCtl (argv) {
1284
1267
  case 'revoke': return cmdRevoke(rest[0])
1285
1268
  case 'secret': return cmdSecret(rest)
1286
1269
  case 'approval': return cmdApproval(rest)
1287
- case 'passwords': return cmdPasswords(rest)
1288
1270
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1289
1271
  case 'logs': return cmdLogs()
1290
1272
  case 'version':
package/src/daemon.js CHANGED
@@ -74,6 +74,9 @@ export async function runDaemon () {
74
74
  writeJson(stateFile, {
75
75
  v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
76
76
  proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
77
+ // Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
78
+ // pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
79
+ autoLockMs: mgr.profiles.autoLockMs,
77
80
  current: mgr.currentId(), profiles: mgr.summary()
78
81
  })
79
82
  }
@@ -105,7 +108,14 @@ export async function runDaemon () {
105
108
  const resolveTarget = (req) => {
106
109
  try {
107
110
  const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
108
- return { id, vault: mgr.get(id), locked: mgr.profiles.isLocked(id) }
111
+ const locked = mgr.profiles.isLocked(id)
112
+ // ESTO CUENTA COMO USO: el candado se cierra solo a los 5 min de no usarse
113
+ // (`profiles.js`), y quien lo usa es esta consola. Va aquí y no en cada `case`
114
+ // porque toda petición de la CLI/TUI pasa por este punto, y así ninguna se olvida.
115
+ // Lo que un aparato pida por el proxy NO pasa por aquí, que es justo lo que se
116
+ // quiere: el candado no es suyo y no debe alargarlo.
117
+ if (!locked) mgr.profiles.touch(id)
118
+ return { id, vault: mgr.get(id), locked }
109
119
  } catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
110
120
  }
111
121
  /** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
@@ -247,8 +257,24 @@ export async function runDaemon () {
247
257
  if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
248
258
  }
249
259
 
260
+ /**
261
+ * BORRA la llave derivada en cuanto se usó. Es lo único de la contraseña que se puede
262
+ * borrar de verdad: un `string` de JS no se puede pisar (lo copia y lo mueve el motor
263
+ * hasta que pase el recolector), pero un `Uint8Array` sí, y es el que lleva el material
264
+ * con el que se abre la copia maestra. Vale lo que vale — no hace milagros con un
265
+ * volcado de memoria tomado en el instante justo —, pero acorta la ventana de horas a
266
+ * milisegundos, que es la diferencia entre «estaba ahí» y «estuvo».
267
+ */
268
+ const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
269
+
250
270
  async function handleProfileRequest (req) {
251
- const ref = () => mgr.resolve(req.profile || mgr.currentId())
271
+ // Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
272
+ // bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
273
+ const ref = () => {
274
+ const id = mgr.resolve(req.profile || mgr.currentId())
275
+ mgr.profiles.touch(id)
276
+ return id
277
+ }
252
278
  switch (req.op) {
253
279
  case 'list': return {} // el volcado de perfiles ya se hace abajo
254
280
  // `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
@@ -266,8 +292,9 @@ export async function runDaemon () {
266
292
  const id = ref()
267
293
  await mgr.profiles.unlock(id, req.password)
268
294
  let note = ''
295
+ let ak = null
269
296
  try {
270
- const ak = await mgr.profiles.adminKey(id, req.password)
297
+ ak = await mgr.profiles.adminKey(id, req.password)
271
298
  // REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
272
299
  // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
273
300
  // reemplazando lo que alguien metiera mal y quitando lo que sobre.
@@ -277,7 +304,8 @@ export async function runDaemon () {
277
304
  if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
278
305
  else if (r?.wrapped) note = ' · llavero al día'
279
306
  } catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
280
- return { done: 'perfil desbloqueado' + note }
307
+ finally { wipe(ak) }
308
+ return { done: 'perfil desbloqueado' + note, autoLockMs: mgr.profiles.autoLockMs }
281
309
  }
282
310
  case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
283
311
  // PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
@@ -291,7 +319,7 @@ export async function runDaemon () {
291
319
  const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
292
320
  await mgr.profiles.setPassword(id, req.password)
293
321
  const nueva = await mgr.profiles.adminKey(id, req.password)
294
- await rekey(id, vieja, nueva)
322
+ try { await rekey(id, vieja, nueva) } finally { wipe(vieja); wipe(nueva) }
295
323
  return { done: 'contraseña guardada' }
296
324
  }
297
325
  // QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
@@ -301,7 +329,7 @@ export async function runDaemon () {
301
329
  const id = ref()
302
330
  if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
303
331
  const vieja = await mgr.profiles.adminKey(id, req.password)
304
- await rekey(id, vieja, null)
332
+ try { await rekey(id, vieja, null) } finally { wipe(vieja) }
305
333
  mgr.profiles.removePassword(id)
306
334
  return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
307
335
  }
@@ -373,6 +401,7 @@ export async function runDaemon () {
373
401
  if (sec?.op) {
374
402
  rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
375
403
  lastSecretError = null
404
+ let ak // fuera del try para poder BORRARLA pase lo que pase (ver `wipe`)
376
405
  try {
377
406
  const vault = targetOf(sec)
378
407
  // Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
@@ -384,7 +413,7 @@ export async function runDaemon () {
384
413
  // convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
385
414
  // a la llave de la máquina, que es la protección de antes de esto (y el vault lo
386
415
  // avisa al arrancar).
387
- const ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
416
+ ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
388
417
  if (sec.op === 'migrate') {
389
418
  // Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
390
419
  // escritura (servicios del cajón + aparatos que administran). Con una lista
@@ -405,11 +434,6 @@ export async function runDaemon () {
405
434
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
406
435
  } else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
407
436
  else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
408
- else if (sec.op === 'passwords') {
409
- const r = await vault.setPasswordDevice(sec.pub, { label: sec.label, on: !!sec.on })
410
- console.log('[vault] passwords: device %s %s', sec.id || '?', sec.on ? 'ALLOWED' : 'removed')
411
- if (sec.on) console.log('[vault] restart the vault so it starts answering password requests')
412
- }
413
437
  else if (sec.op === 'approval') { const r = await vault.setApproval(sec.pub, !!sec.approval); console.log('[vault] device %s: approval %s', sec.id || '?', r.approval ? 'REQUIRED on every key request' : 'off') }
414
438
  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) }
415
439
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
@@ -445,7 +469,7 @@ export async function runDaemon () {
445
469
  code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
446
470
  }
447
471
  console.error('[vault] secret failed:', e.message)
448
- }
472
+ } finally { wipe(ak) }
449
473
  }
450
474
  // Perfiles / candado.
451
475
  const preq = readJsonSafe(profileReqFile)
package/src/manager.js CHANGED
@@ -35,14 +35,20 @@ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
35
35
  throw e
36
36
  }
37
37
 
38
- export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
38
+ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, autoLockMs } = {}) {
39
39
  ensureDir(root)
40
40
  // El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
41
41
  // se instala apuntando a la RAÍZ (no al dir de un perfil) para que los perfiles
42
42
  // no se peleen por el archivo ni se lo lleven al borrarse.
43
43
  installNodeGlobals(root)
44
44
 
45
- const profiles = openProfiles(root)
45
+ const profiles = openProfiles(root, {
46
+ ...(autoLockMs === undefined ? {} : { autoLockMs }),
47
+ // Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
48
+ // pidiendo la contraseña otra vez cree que algo se rompió.
49
+ onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
50
+ id, Math.round(profiles.autoLockMs / 60000))
51
+ })
46
52
  const migrated = profiles.migrate()
47
53
  if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
48
54
 
package/src/profiles.js CHANGED
@@ -52,6 +52,25 @@ const PWD_MIN = 12
52
52
  */
53
53
  const TRIES_FORGET_MS = 15 * 60 * 1000
54
54
 
55
+ /**
56
+ * BLOQUEO AUTOMÁTICO. Cuánto aguanta abierto el candado sin que nadie lo use.
57
+ *
58
+ * Abrir la bóveda dejaba la consola abierta hasta que alguien la cerraba a mano o
59
+ * reiniciaba el servicio — y el servicio de un PC de escritorio no se reinicia en semanas.
60
+ * O sea que teclear la contraseña una vez un lunes dejaba la máquina administrable para
61
+ * quien pasara por delante el jueves. Un candado que solo se cierra reiniciando no es un
62
+ * candado.
63
+ *
64
+ * El plazo se cuenta desde el ÚLTIMO USO (`touch`), no desde que se abrió: quien está
65
+ * trabajando no se queda fuera a media faena, y quien se levanta de la silla lo encuentra
66
+ * cerrado. Y vence solo, sin temporizador: se mira la hora al preguntar (`isOpen`), así
67
+ * que no hay nada que limpiar ni un `setTimeout` que mantenga vivo al proceso.
68
+ *
69
+ * Como todo el candado, esto es de la CONSOLA: al vencer, los aparatos ya emparejados
70
+ * siguen firmando, leyendo y guardando. Lo que se cierra es administrar y mirar.
71
+ */
72
+ const AUTO_LOCK_MS = 5 * 60 * 1000
73
+
55
74
  /**
56
75
  * Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
57
76
  *
@@ -91,7 +110,13 @@ async function derivePwd (password, saltB64, iter) {
91
110
  const newId = () => 'p' + crypto.randomUUID().slice(0, 8)
92
111
  const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
93
112
 
94
- export function openProfiles (root = dataDir()) {
113
+ /**
114
+ * @param {string} root dir de datos
115
+ * @param {{ autoLockMs?: number, onAutoLock?: (id: string) => void }} [opts]
116
+ * `autoLockMs`: 0 desactiva el bloqueo automático (las pruebas lo acortan).
117
+ * `onAutoLock`: se avisa cuando un perfil se cierra solo, para poder decirlo en el log.
118
+ */
119
+ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onAutoLock = null } = {}) {
95
120
  const file = path.join(root, REGISTRY)
96
121
  // CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
97
122
  // lleva dentro el verificador del candado. No protege de quien tenga el disco entero
@@ -107,8 +132,24 @@ export function openProfiles (root = dataDir()) {
107
132
  const save = () => writeJson(file, data, atRest)
108
133
 
109
134
  // Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
110
- // vuelve a bloquear, igual que cerrar la pestaña en el navegador).
111
- const unlocked = new Set()
135
+ // vuelve a bloquear, igual que cerrar la pestaña en el navegador), cada uno con la
136
+ // hora a la que se cierra solo si nadie lo usa (ver AUTO_LOCK_MS).
137
+ const unlocked = new Map() // id -> vence (ms epoch)
138
+
139
+ /** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
140
+ const isOpen = (id) => {
141
+ const until = unlocked.get(id)
142
+ if (until == null) return false
143
+ if (autoLockMs > 0 && Date.now() >= until) {
144
+ unlocked.delete(id)
145
+ // El aviso va después de borrarlo: quien lo escuche verá el perfil ya cerrado.
146
+ try { onAutoLock?.(id) } catch (_) {}
147
+ return false
148
+ }
149
+ return true
150
+ }
151
+ /** Abre (o estira) el plazo. Todo lo que abre el candado pasa por aquí. */
152
+ const open = (id) => { unlocked.set(id, Date.now() + (autoLockMs > 0 ? autoLockMs : Number.MAX_SAFE_INTEGER)) }
112
153
 
113
154
  const find = (id) => data.profiles.find((p) => p.id === id) || null
114
155
  const dirOf = (id) => path.join(root, 'p', id)
@@ -118,7 +159,10 @@ export function openProfiles (root = dataDir()) {
118
159
  name: p.name || '',
119
160
  createdAt: p.createdAt || null,
120
161
  protected: !!p.pwd,
121
- locked: !!p.pwd && !unlocked.has(p.id),
162
+ locked: !!p.pwd && !isOpen(p.id),
163
+ // Hasta cuándo sigue abierto si nadie lo toca. Es DATO: la consola lo enseña para
164
+ // que el cierre no llegue por sorpresa.
165
+ ...(p.pwd && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
122
166
  current: p.id === data.current,
123
167
  // Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
124
168
  ...(p.adopt ? { adopt: true } : {})
@@ -231,7 +275,20 @@ export function openProfiles (root = dataDir()) {
231
275
  // ----- candado -----
232
276
 
233
277
  isProtected: (id) => !!find(id)?.pwd,
234
- isLocked: (id) => { const p = find(id); return !!p?.pwd && !unlocked.has(id) },
278
+ isLocked: (id) => { const p = find(id); return !!p?.pwd && !isOpen(id) },
279
+ /** Cuánto dura abierto el candado sin usarse (ms). 0 = no se cierra solo. */
280
+ get autoLockMs () { return autoLockMs },
281
+ /**
282
+ * «Se acaba de usar»: estira el plazo del bloqueo automático. Lo llama la consola
283
+ * al atender CADA petición suya, y solo eso cuenta como uso: que un aparato pida su
284
+ * configuración no abre nada ni alarga nada, porque el candado no es suyo.
285
+ * Devuelve si el perfil estaba abierto (a uno cerrado no hay nada que estirarle).
286
+ */
287
+ touch (id) {
288
+ if (!isOpen(id)) return false
289
+ open(id)
290
+ return true
291
+ },
235
292
  assertUnlocked (id) {
236
293
  if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
237
294
  },
@@ -258,7 +315,7 @@ export function openProfiles (root = dataDir()) {
258
315
 
259
316
  async unlock (id, password) {
260
317
  const p = assertExists(id)
261
- if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
318
+ if (!p.pwd) { open(id); return { ok: true, locked: false } }
262
319
  // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
263
320
  // fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
264
321
  // Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
@@ -296,7 +353,7 @@ export function openProfiles (root = dataDir()) {
296
353
  }
297
354
  delete p.tries
298
355
  save()
299
- unlocked.add(id)
356
+ open(id)
300
357
  return { ok: true, locked: false }
301
358
  },
302
359
 
@@ -314,7 +371,7 @@ export function openProfiles (root = dataDir()) {
314
371
  p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
315
372
  delete p.tries
316
373
  save()
317
- unlocked.add(id)
374
+ open(id)
318
375
  return entry(p)
319
376
  },
320
377
 
@@ -324,7 +381,7 @@ export function openProfiles (root = dataDir()) {
324
381
  delete p.pwd
325
382
  delete p.tries
326
383
  save()
327
- unlocked.add(id)
384
+ open(id)
328
385
  return entry(p)
329
386
  }
330
387
  }
package/src/tui/app.js CHANGED
@@ -318,7 +318,7 @@ function pairModeRows (st, t) {
318
318
  * una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
319
319
  * a ese aparato meter y sacar dispositivos sin venir aquí.
320
320
  */
321
- const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
321
+ const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'passwords']
322
322
 
323
323
  function capsRows (st, t) {
324
324
  const i = L(st)
@@ -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
@@ -45,13 +45,15 @@ const es = {
45
45
  sign: 'Firmar como tú',
46
46
  store: 'Guardar tus datos',
47
47
  read: 'Leer tus datos',
48
- admin: 'Administrar el perfil'
48
+ admin: 'Administrar el perfil',
49
+ passwords: 'Pedir tus contraseñas'
49
50
  },
50
51
  capHint: {
51
52
  sign: 'usar tu identidad en las apps del ecosistema',
52
53
  store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
53
54
  read: 'ver lo que guardaste',
54
- admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí'
55
+ admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí',
56
+ passwords: 'el gestor: pedir una credencial por sitio, nunca la lista entera'
55
57
  },
56
58
  confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
57
59
  capGiven: (n) => `Concedido: ${n}`,
@@ -124,6 +126,7 @@ const es = {
124
126
  vaultUnlocked: 'Bóveda desbloqueada',
125
127
  lockingVault: 'Bloqueando…',
126
128
  vaultLocked: 'Bóveda bloqueada',
129
+ autoLocked: (min) => `Se bloqueó sola tras ${min} min sin usarse · la contraseña se olvidó`,
127
130
 
128
131
  // dispositivos
129
132
  pendingDevice: (id) => ` ⧗ PENDIENTE: ${id}`,
@@ -341,13 +344,15 @@ const en = {
341
344
  sign: 'Sign as you',
342
345
  store: 'Save your data',
343
346
  read: 'Read your data',
344
- admin: 'Manage the profile'
347
+ admin: 'Manage the profile',
348
+ passwords: 'Ask for your passwords'
345
349
  },
346
350
  capHint: {
347
351
  sign: 'use your identity across the ecosystem apps',
348
352
  store: 'write to your vault (profile, content, sensitive data)',
349
353
  read: 'see what you saved',
350
- admin: 'connect and remove devices from that device, without coming here'
354
+ admin: 'connect and remove devices from that device, without coming here',
355
+ passwords: 'the manager: ask for one credential per site, never the whole list'
351
356
  },
352
357
  confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
353
358
  capGiven: (n) => `Granted: ${n}`,
@@ -418,6 +423,7 @@ const en = {
418
423
  vaultUnlocked: 'Vault unlocked',
419
424
  lockingVault: 'Locking…',
420
425
  vaultLocked: 'Vault locked',
426
+ autoLocked: (min) => `Locked itself after ${min} min idle · the password was forgotten`,
421
427
 
422
428
  pendingDevice: (id) => ` ⧗ PENDING: ${id}`,
423
429
  pendingHint: ' — press A to approve, X to reject',
package/src/vault.js CHANGED
@@ -102,9 +102,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
102
102
  return { approval: !!on }
103
103
  }
104
104
  // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
105
- // reposo como todo lo demás. Quién puede pedir es una LISTA DE ESTA BÓVEDA, no del
106
- // acta por lo mismo que `approval.json`: es ella la que entrega. El acta sigue
107
- // mandando en que el aparato exista y no esté revocado, y eso se comprueba aparte.
105
+ // reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
106
+ // `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
107
+ // aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
108
+ // además un emparejamiento paralelo al del ecosistema.
108
109
  const passwordsFile = path.join(dir, 'passwords.json')
109
110
  const passwordsAtRest = atRestFor(dir)
110
111
  const readPasswordsFile = () => {
@@ -118,13 +119,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
118
119
  const passwordsStore = {
119
120
  async get (k) { return readPasswordsFile()?.data?.[k] },
120
121
  async set (k, v) {
121
- const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
122
+ const d = readPasswordsFile() || { v: 1, data: {} }
122
123
  d.data = { ...(d.data || {}), [k]: v }
123
124
  writePasswordsFile(d)
124
125
  },
125
126
  }
126
127
 
127
- const passwordDevices = () => readPasswordsFile()?.devices || []
128
+ /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
129
+ const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
128
130
 
129
131
  /**
130
132
  * El acta vigente, en caché.
@@ -153,20 +155,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
153
155
  }
154
156
  const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
155
157
  const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
156
- writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64'), devices: d?.devices || [] })
158
+ writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64') })
157
159
  return key
158
160
  }
159
161
 
160
- /** Autorizar/retirar un aparato para pedir contraseñas (`dotrino-vault passwords`). */
161
- async function setPasswordDevice (pub, { label = '', on = true } = {}) {
162
- const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
163
- const resto = (d.devices || []).filter((x) => x.pubkey !== pub)
164
- if (on) resto.push({ pubkey: pub, label: String(label || '').slice(0, 60), ts: Date.now() })
165
- writePasswordsFile({ ...d, devices: resto })
166
- audit('passwords.device', { device: await deviceIdOf(pub).catch(() => null), on: !!on })
167
- return { ok: true }
168
- }
169
-
170
162
  const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
171
163
  const secrets = openSecretsStore(dir, {
172
164
  sealer: makeSealer(),
@@ -764,69 +756,94 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
764
756
  // ----- LA BÓVEDA DE CONTRASEÑAS -----
765
757
  //
766
758
  // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
767
- // la CA. Solo se levanta si hay algún aparato autorizado — sin eso no hay a quién
759
+ // la CA. Solo se levanta cuando el acta autoriza a alguien — sin eso no hay a quién
768
760
  // responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
761
+ //
762
+ // Y se levanta EN CUANTO pasa, no en el siguiente arranque: conceder el permiso ya es
763
+ // un acto explícito del dueño, y pedirle además que reinicie la bóveda es de las cosas
764
+ // que se olvidan y luego parecen un fallo del gestor.
769
765
  let passwords = null
770
- try {
771
- if (passwordDevices().length) {
772
- const { createPasswordDesk } = await import('./passwords.js')
773
- await refreshActa()
774
- const actaSweeper = setInterval(refreshActa, 5000)
775
- actaSweeper.unref?.()
776
- passwords = createPasswordDesk({
777
- client,
778
- store: passwordsStore,
779
- cek: await passwordsKey(),
780
- // DOS condiciones, y hacen falta las dos: que esta bóveda lo haya autorizado
781
- // (su lista) y que el ACTA siga reconociéndolo. Revocar un aparato en el acta
782
- // le corta esto también, sin tener que acordarse de dos sitios.
783
- isAllowed: (pub) => {
784
- if (!passwordDevices().some((d) => d.pubkey === pub)) return false
785
- return (actaCache?.members || []).some((m) => m.pub === pub)
786
- },
787
- encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
788
- needsApproval: (pub) => needsApproval(pub),
789
- // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
790
- // Es el mismo camino que ya recorren los cajones de secretos.
791
- approve: async ({ pubkey, op }) => {
792
- const deviceId = await deviceIdOf(pubkey).catch(() => null)
793
- const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
794
- const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
795
- audit('passwords.pending', { device: deviceId, id: pend.id, op })
796
- log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
797
- const espera = new Promise((resolve) => {
798
- waiters.set(pend.id, resolve)
799
- // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
800
- // y el aparato recibe un no en vez de esperar sin fin.
801
- const t = setTimeout(() => {
802
- if (waiters.delete(pend.id)) {
803
- audit('passwords.expired', { device: deviceId, id: pend.id })
804
- resolve(false)
805
- }
806
- }, PENDING_TTL_MS)
807
- t.unref?.()
808
- })
809
- await notifyApprovers(pend, actaCache)
810
- return espera
811
- },
812
- audit,
813
- log,
814
- }).start()
815
- log('[vault] passwords: atendiendo peticiones de %d aparato(s)', passwordDevices().length)
816
- // El código que se pega en la extensión o en la consola web. Lleva las DOS
817
- // públicas: por la de firma enruta el proxio, a la de cifrado se le sella el
818
- // contenido. Es público: no hay nada aquí que no pueda verse.
819
- try {
820
- const codigo = Buffer.from(JSON.stringify({
821
- v: 1,
822
- sign: await client.getPublicKey(),
823
- enc: await identity.getEncryptionPubkey(),
824
- })).toString('base64url')
825
- log('[vault] passwords: enlaza un aparato con este código:\n%s', codigo)
826
- } catch (e) {
827
- log('[vault] passwords: no se pudo componer el código de enlace: %s', e.message)
766
+ /**
767
+ * EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
768
+ * (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
769
+ *
770
+ * Va aquí y no en el cliente del transporte porque el cliente del vault es UNO y lo
771
+ * comparte todo: el protocolo de la CA viaja en claro a propósito (un enrolamiento es
772
+ * público hasta que hay cert). Lo que se sella es lo del gestor, y por eso `isSealed`
773
+ * mira su marca y no otra.
774
+ *
775
+ * Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
776
+ * abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
777
+ */
778
+ const passwordSealing = {
779
+ async seal (msg, peerEncPub) {
780
+ if (!peerEncPub) throw Object.assign(new Error('no encryption key'), { code: 'unsealed' })
781
+ return {
782
+ app: 'passmanager',
783
+ // Destinatarios como OBJETOS y no como llaves sueltas: `encrypt` expande cada
784
+ // uno a todos los aparatos de esa persona, y una cadena a secas se le cae por
785
+ // el `continue` sin envolver nada — el sobre salía vacío y sin error.
786
+ sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
787
+ from: await identity.getEncryptionPubkey()
828
788
  }
829
- }
789
+ },
790
+ // `decrypt` devuelve `{ plaintext }`, no la cadena.
791
+ async open (env) { return JSON.parse((await identity.decrypt(env.from, null, env.sealed)).plaintext) },
792
+ isSealed: (m) => !!m && m.app === 'passmanager' && !!m.sealed
793
+ }
794
+ const startPasswordDesk = async () => {
795
+ if (passwords || !passwordDevices().length) return
796
+ const { createPasswordDesk } = await import('./passwords.js')
797
+ client.updateConfig({ sealing: passwordSealing })
798
+ passwords = createPasswordDesk({
799
+ client,
800
+ store: passwordsStore,
801
+ cek: await passwordsKey(),
802
+ // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
803
+ // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
804
+ // que haya una segunda lista que acordarse de tocar.
805
+ isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
806
+ encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
807
+ needsApproval: (pub) => needsApproval(pub),
808
+ // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
809
+ // Es el mismo camino que ya recorren los cajones de secretos.
810
+ approve: async ({ pubkey, op }) => {
811
+ const deviceId = await deviceIdOf(pubkey).catch(() => null)
812
+ const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
813
+ const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
814
+ audit('passwords.pending', { device: deviceId, id: pend.id, op })
815
+ log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
816
+ const espera = new Promise((resolve) => {
817
+ waiters.set(pend.id, resolve)
818
+ // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
819
+ // y el aparato recibe un no en vez de esperar sin fin.
820
+ const t = setTimeout(() => {
821
+ if (waiters.delete(pend.id)) {
822
+ audit('passwords.expired', { device: deviceId, id: pend.id })
823
+ resolve(false)
824
+ }
825
+ }, PENDING_TTL_MS)
826
+ t.unref?.()
827
+ })
828
+ await notifyApprovers(pend, actaCache)
829
+ return espera
830
+ },
831
+ audit,
832
+ log,
833
+ }).start()
834
+ log('[vault] passwords: serving %d device(s)', passwordDevices().length)
835
+ }
836
+ try {
837
+ // El acta ANTES de mirarla: es ella la que dice si hay a quién responder, y sin
838
+ // refrescarla primero el mostrador no se levantaba nunca en un arranque en frío.
839
+ await refreshActa()
840
+ const actaSweeper = setInterval(() => {
841
+ refreshActa().then(startPasswordDesk).catch(() => {})
842
+ }, 5000)
843
+ actaSweeper.unref?.()
844
+ if (passwordDevices().length) await startPasswordDesk()
845
+ // Ni código de enlace ni pegar nada: un gestor entra como cualquier otro aparato.
846
+ else log('[vault] passwords: no device may ask yet · pair one with `dotrino-vault pair --scope contrasenas`')
830
847
  } catch (e) {
831
848
  log('[vault] passwords: no se pudo levantar (%s); el resto sigue igual', e.message)
832
849
  }
@@ -1730,7 +1747,6 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1730
1747
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
1731
1748
  // llave. Lo que no puede es listarla un aparato.
1732
1749
  passwordDevices,
1733
- setPasswordDevice,
1734
1750
  passwordsVault: () => passwords?.vault || null,
1735
1751
  listApprovals: () => approvals.list(),
1736
1752
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus