@dotrino/vaultd 0.52.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.js CHANGED
@@ -16,8 +16,10 @@
16
16
  */
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
+ import { takeLock } from '../lib/src/lock.js'
19
20
  import { startVaultManager } from './manager.js'
20
21
  import { dataDir, writeJson, readJson } from './paths.js'
22
+ import { parseInvite } from '../lib/src/invite.js'
21
23
  import { VERSION } from './version.js'
22
24
 
23
25
  const readJsonSafe = (f) => readJson(f, null)
@@ -32,21 +34,33 @@ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
32
34
  * datos, los dos con tu identidad y los dos conectados al proxy; el segundo pisaba el pid
33
35
  * de `state.json`, así que el CLI solo le hablaba a uno y el otro quedaba de fantasma.
34
36
  *
35
- * El candado es el propio `state.json`: si el pid que hay sigue vivo, no arrancamos. Un
36
- * pid muerto (se cortó la luz) no estorba. Con `DOTRINO_VAULT_DIR` distintos conviven
37
- * cuantas quieras: lo que colisiona es el directorio, no el programa.
37
+ * El candado vive en `vault.lock` (ver `lib/src/lock.js`) y funciona TAMBIÉN entre
38
+ * máquinas: `O_EXCL` para la exclusión y un latido para que un corte de luz no deje el
39
+ * directorio bloqueado para siempre. Antes era un pid, y un pid no cruza contenedores ni
40
+ * hosts — dos contenedores sobre el mismo volumen arrancaban los dos.
41
+ *
42
+ * Con `DOTRINO_VAULT_DIR` distintos conviven cuantas quieras, y es así como se ponen
43
+ * varias bóvedas en un mismo disco: cada una con su directorio entero, sin nada
44
+ * compartido. Lo que colisiona es el directorio, no el programa.
38
45
  */
39
46
  function assertSingleInstance (dir) {
40
- let s = null
41
- try { s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')) } catch (_) { return }
42
- const pid = Number(s?.pid)
43
- if (!pid || pid === process.pid) return
44
- try { process.kill(pid, 0) } catch (_) { return } // no existe: el candado es de un muerto
45
- console.error('A vault is already running on this data (process %d).', pid)
46
- console.error(' data: %s', dir)
47
- console.error('Two vaults on the same directory step on each other: stop the other one,')
48
- console.error('or use DOTRINO_VAULT_DIR to give this one its own directory.')
49
- process.exit(3)
47
+ try {
48
+ const { release } = takeLock(dir)
49
+ // Se suelta al salir por las buenas. Si el proceso muere de golpe el candado se queda,
50
+ // y por eso caduca por latido: uno inmortal sería peor que ninguno.
51
+ const soltar = () => { try { release() } catch (_) {} }
52
+ process.once('exit', soltar)
53
+ for (const sig of ['SIGINT', 'SIGTERM']) process.once(sig, () => { soltar(); process.exit(0) })
54
+ } catch (e) {
55
+ if (e?.code !== 'vault-locked') throw e
56
+ console.error('A vault is already running on this data.')
57
+ console.error(' %s', e.message)
58
+ console.error(' data: %s', dir)
59
+ console.error('Two vaults on the same directory are not two vaults: they are the SAME one')
60
+ console.error('running twice — same master key, both sealing records as the same sealer.')
61
+ console.error('Stop the other one, or use DOTRINO_VAULT_DIR to give this one its own.')
62
+ process.exit(3)
63
+ }
50
64
  }
51
65
 
52
66
  export async function runDaemon () {
@@ -74,6 +88,9 @@ export async function runDaemon () {
74
88
  writeJson(stateFile, {
75
89
  v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
76
90
  proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
91
+ // Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
92
+ // pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
93
+ autoLockMs: mgr.profiles.autoLockMs,
77
94
  current: mgr.currentId(), profiles: mgr.summary()
78
95
  })
79
96
  }
@@ -105,7 +122,14 @@ export async function runDaemon () {
105
122
  const resolveTarget = (req) => {
106
123
  try {
107
124
  const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
108
- return { id, vault: mgr.get(id), locked: mgr.profiles.isLocked(id) }
125
+ const locked = mgr.profiles.isLocked(id)
126
+ // ESTO CUENTA COMO USO: el candado se cierra solo a los 5 min de no usarse
127
+ // (`profiles.js`), y quien lo usa es esta consola. Va aquí y no en cada `case`
128
+ // porque toda petición de la CLI/TUI pasa por este punto, y así ninguna se olvida.
129
+ // Lo que un aparato pida por el proxy NO pasa por aquí, que es justo lo que se
130
+ // quiere: el candado no es suyo y no debe alargarlo.
131
+ if (!locked) mgr.profiles.touch(id)
132
+ return { id, vault: mgr.get(id), locked }
109
133
  } catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
110
134
  }
111
135
  /** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
@@ -119,6 +143,19 @@ export async function runDaemon () {
119
143
  // --- SIGUSR1: iniciar emparejamiento ---
120
144
  const pairFile = path.join(dir, 'pair.json')
121
145
  let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
146
+ /**
147
+ * Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
148
+ *
149
+ * Sigue en pie la regla de que **ningún QR concede administración**: el QR no lleva
150
+ * nada. Lo que hay es una nota LOCAL de esta bóveda, y el permiso se aplica en el
151
+ * mismo gesto que ya era la puerta —aprobar con el código tecleado aquí—, exactamente
152
+ * igual que `--approval`. Es el mismo `caps <ID> +administra` que harías a mano un
153
+ * segundo después, sin tener que ir a buscar el ID.
154
+ *
155
+ * Existe por el contenedor: allí cada paso cuesta un `docker exec`, y el primer aparato
156
+ * de una bóveda recién desplegada es SIEMPRE la consola.
157
+ */
158
+ let pendingAdmin = false
122
159
  const pairReqFile = path.join(dir, 'pair-request.json')
123
160
  async function handlePairingRequest () {
124
161
  try {
@@ -164,6 +201,7 @@ export async function runDaemon () {
164
201
  // `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
165
202
  // teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
166
203
  pendingApproval = !!pairReq?.approval
204
+ pendingAdmin = !!pairReq?.admin
167
205
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
168
206
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
169
207
  // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
@@ -199,6 +237,10 @@ export async function runDaemon () {
199
237
  const rejectReqFile = path.join(dir, 'reject-request.json')
200
238
  const revokeReqFile = path.join(dir, 'revoke-request.json')
201
239
  const secretReqFile = path.join(dir, 'secret-request.json')
240
+ // MULTIVAULT: esta bóveda se UNE a la cuenta de otra. Va por aquí y no por `pair` porque
241
+ // es el papel contrario — aquí no se invita a nadie, se acepta una invitación ajena.
242
+ const joinReqFile = path.join(dir, 'join-request.json')
243
+ const joinResFile = path.join(dir, 'join.json')
202
244
  const secretsListFile = path.join(dir, 'secrets-list.json')
203
245
  /**
204
246
  * Por qué falló la última orden de variables. La consola pide el cambio y el volcado
@@ -247,13 +289,29 @@ export async function runDaemon () {
247
289
  if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
248
290
  }
249
291
 
292
+ /**
293
+ * BORRA la llave derivada en cuanto se usó. Es lo único de la contraseña que se puede
294
+ * borrar de verdad: un `string` de JS no se puede pisar (lo copia y lo mueve el motor
295
+ * hasta que pase el recolector), pero un `Uint8Array` sí, y es el que lleva el material
296
+ * con el que se abre la copia maestra. Vale lo que vale — no hace milagros con un
297
+ * volcado de memoria tomado en el instante justo —, pero acorta la ventana de horas a
298
+ * milisegundos, que es la diferencia entre «estaba ahí» y «estuvo».
299
+ */
300
+ const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
301
+
250
302
  async function handleProfileRequest (req) {
251
- const ref = () => mgr.resolve(req.profile || mgr.currentId())
303
+ // Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
304
+ // bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
305
+ const ref = () => {
306
+ const id = mgr.resolve(req.profile || mgr.currentId())
307
+ mgr.profiles.touch(id)
308
+ return id
309
+ }
252
310
  switch (req.op) {
253
311
  case 'list': return {} // el volcado de perfiles ya se hace abajo
254
312
  // `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
255
313
  // cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
256
- case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt }); return { done: `perfil creado: ${p.name || p.id}`, id: p.id, adopt: !!req.adopt } }
314
+ case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt, kek: req.kek || null }); return { done: `perfil creado: ${p.name || p.id}${req.kek ? ' (clave del disco en el KMS)' : ''}`, id: p.id, adopt: !!req.adopt } }
257
315
  case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
258
316
  case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
259
317
  case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
@@ -266,8 +324,9 @@ export async function runDaemon () {
266
324
  const id = ref()
267
325
  await mgr.profiles.unlock(id, req.password)
268
326
  let note = ''
327
+ let ak = null
269
328
  try {
270
- const ak = await mgr.profiles.adminKey(id, req.password)
329
+ ak = await mgr.profiles.adminKey(id, req.password)
271
330
  // REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
272
331
  // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
273
332
  // reemplazando lo que alguien metiera mal y quitando lo que sobre.
@@ -277,7 +336,8 @@ export async function runDaemon () {
277
336
  if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
278
337
  else if (r?.wrapped) note = ' · llavero al día'
279
338
  } catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
280
- return { done: 'perfil desbloqueado' + note }
339
+ finally { wipe(ak) }
340
+ return { done: 'perfil desbloqueado' + note, autoLockMs: mgr.profiles.autoLockMs }
281
341
  }
282
342
  case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
283
343
  // PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
@@ -291,7 +351,7 @@ export async function runDaemon () {
291
351
  const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
292
352
  await mgr.profiles.setPassword(id, req.password)
293
353
  const nueva = await mgr.profiles.adminKey(id, req.password)
294
- await rekey(id, vieja, nueva)
354
+ try { await rekey(id, vieja, nueva) } finally { wipe(vieja); wipe(nueva) }
295
355
  return { done: 'contraseña guardada' }
296
356
  }
297
357
  // QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
@@ -301,7 +361,7 @@ export async function runDaemon () {
301
361
  const id = ref()
302
362
  if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
303
363
  const vieja = await mgr.profiles.adminKey(id, req.password)
304
- await rekey(id, vieja, null)
364
+ try { await rekey(id, vieja, null) } finally { wipe(vieja) }
305
365
  mgr.profiles.removePassword(id)
306
366
  return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
307
367
  }
@@ -326,6 +386,18 @@ export async function runDaemon () {
326
386
  const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
327
387
  if (pendingApproval && r?.cert?.sub) { try { await vault.setApproval(r.cert.sub, true); console.log('[vault] the new device will need approval on every key request') } catch (e) { console.error('[vault] could not flag approval: %s', e.message) } }
328
388
  pendingApproval = false
389
+ // `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
390
+ // permisos — el aparato acaba de entrar con el scope que pidió la invitación.
391
+ if (pendingAdmin && r?.deviceId) {
392
+ try {
393
+ const rec = await vault.profileMembers()
394
+ const m = (rec?.members || []).find((x) => x.id === r.deviceId)
395
+ if (!m?.pub) throw new Error('the device is not in the record yet')
396
+ await vault.setCaps(m.pub, [...new Set([...(m.caps || []), 'admin'])])
397
+ console.log('[vault] the new device can ADMINISTER this account (console)')
398
+ } catch (e) { console.error('[vault] could not grant admin: %s', e.message) }
399
+ }
400
+ pendingAdmin = false
329
401
  console.log('[vault] aprobado %s', r.deviceId)
330
402
  answer({ ok: true, deviceId: r.deviceId || null })
331
403
  } catch (e) {
@@ -366,6 +438,62 @@ export async function runDaemon () {
366
438
  } catch (e) { console.error('[vault] revocation failed:', e.message) }
367
439
  rm(revokeReqFile)
368
440
  }
441
+ /**
442
+ * UNIRSE A LA CUENTA DE OTRA BÓVEDA (multivault). La llave que entra como miembro es
443
+ * la de ESTA bóveda —no una de aparato inventada—, que es lo que después permite
444
+ * darle `+sella` y que sea el respaldo de verdad de la otra.
445
+ *
446
+ * El código de confirmación lo genera esta bóveda y hay que TIPEARLO en la otra: la
447
+ * misma defensa de siempre, para que una invitación interceptada no baste.
448
+ */
449
+ const join = readJsonSafe(joinReqFile)
450
+ if (join?.qr) {
451
+ rm(joinReqFile)
452
+ rm(joinResFile)
453
+ // LA CUENTA AJENA VA EN UN PERFIL DEL GESTOR, no en una cuenta interna de la
454
+ // identidad. Antes esto usaba `enrollDevice(…, { join: 'new' })`, que crea una
455
+ // cuenta más DENTRO de la identidad de un perfil que ya existía —y el gestor no se
456
+ // enteraba—: no había instancia de bóveda para ella, nadie se identificaba en el
457
+ // proxio con esa llave, y el aviso de la otra bóveda —con el acta donde acababa de
458
+ // conceder `sella`— no llegaba a ninguna parte. Se unía y no servía para nada.
459
+ //
460
+ // Un perfil nace vacío y `adopt: true`, que es la marca que deja a `joinProfile`
461
+ // cambiar su acta recién nacida por la que traiga la otra bóveda; sin ella, unirse
462
+ // sería pisar una cuenta con datos y se rechaza, que es lo correcto por defecto.
463
+ let nacido = null
464
+ try {
465
+ const p = await mgr.add(join.name || 'cuenta de la otra bóveda', { adopt: true, kek: join.kek || null })
466
+ nacido = p.id
467
+ const id = mgr.get(p.id)?.identity
468
+ if (!id) throw new Error('no identity for the new profile')
469
+ const off = id.onVault?.((e) => {
470
+ if (e?.phase === 'challenge' && e.code) {
471
+ console.log('[vault] type this code in the other vault: %s', e.code)
472
+ writeJson(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
473
+ }
474
+ })
475
+ // `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
476
+ // llave recién hecha es la que entra en el acta de la otra bóveda. Crear ahí
477
+ // dentro otra cuenta más sería el bug de arriba otra vez, un nivel más abajo.
478
+ const r = await id.enrollDevice(join.qr, { label: join.label || 'bóveda', join: 'current' })
479
+ off?.()
480
+ // LA CUENTA RECIÉN ADOPTADA PASA A SER LA ACTIVA, y no es un capricho: es la
481
+ // razón por la que se hizo el `join`. Sin esto, el `Perfil 1` vacío que nace en
482
+ // el primer arranque sigue siendo el destino por defecto y todo lo que hagas
483
+ // después —emparejar un aparato, aprobarlo— entra en la cuenta equivocada sin
484
+ // decir nada. En un contenedor que arranca para respaldar una cuenta, ese perfil
485
+ // vacío no es más que un accidente del primer arranque.
486
+ try { mgr.profiles.setCurrent(p.id) } catch (_) {}
487
+ console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r?.acta?.seq ?? '?', p.id)
488
+ writeJson(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
489
+ } catch (e) {
490
+ console.error('[vault] could not join:', e.message)
491
+ // El perfil nació para esto y está vacío: si el intento no llegó a término se va
492
+ // con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
493
+ if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
494
+ writeJson(joinResFile, { at: Date.now(), state: 'error', error: e.message })
495
+ }
496
+ }
369
497
  // Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
370
498
  // del CLI o de la TUI. El archivo con el valor vive un instante en el mismo dir
371
499
  // 0700 del vault y se borra al consumir.
@@ -373,6 +501,7 @@ export async function runDaemon () {
373
501
  if (sec?.op) {
374
502
  rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
375
503
  lastSecretError = null
504
+ let ak // fuera del try para poder BORRARLA pase lo que pase (ver `wipe`)
376
505
  try {
377
506
  const vault = targetOf(sec)
378
507
  // Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
@@ -384,7 +513,7 @@ export async function runDaemon () {
384
513
  // convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
385
514
  // a la llave de la máquina, que es la protección de antes de esto (y el vault lo
386
515
  // avisa al arrancar).
387
- const ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
516
+ ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
388
517
  if (sec.op === 'migrate') {
389
518
  // Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
390
519
  // escritura (servicios del cajón + aparatos que administran). Con una lista
@@ -440,7 +569,7 @@ export async function runDaemon () {
440
569
  code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
441
570
  }
442
571
  console.error('[vault] secret failed:', e.message)
443
- }
572
+ } finally { wipe(ak) }
444
573
  }
445
574
  // Perfiles / candado.
446
575
  const preq = readJsonSafe(profileReqFile)
@@ -621,6 +750,59 @@ export async function runDaemon () {
621
750
  process.on('SIGTERM', () => shutdown('SIGTERM'))
622
751
  process.on('SIGINT', () => shutdown('SIGINT'))
623
752
 
753
+ /**
754
+ * ARRANCAR YA UNIDO A UNA CUENTA — lo que hace desplegable un contenedor.
755
+ *
756
+ * El problema del contenedor no es la bóveda: es el PRIMER APARATO. Una bóveda recién
757
+ * levantada tiene que **enseñar** una invitación y **recibir** de vuelta un código
758
+ * tecleado, y un contenedor no tiene pantalla ni teclado. Con una sola bóveda no hay
759
+ * salida: alguien tiene que entrar (`docker exec`, `kubectl exec`, ECS Exec).
760
+ *
761
+ * Con dos, sí la hay, y es este camino: **la invitación la hace la bóveda que TIENE un
762
+ * humano delante** (tu PC) y el contenedor solo la acepta. Entonces todo lo interactivo
763
+ * pasa del lado donde hay alguien —el código se teclea allí— y el contenedor no
764
+ * necesita más que dos cosas que ya tiene: la invitación al arrancar, y su registro.
765
+ *
766
+ * docker run -e DOTRINO_JOIN="$(dotrino-vault pair --quiet)" …
767
+ * docker logs -f dotrino-vault → «type this code in the other vault: 123456»
768
+ * dotrino-vault approve 123456 (en tu PC)
769
+ *
770
+ * `DOTRINO_JOIN_FILE` es lo mismo apuntando a un archivo, y es lo que hay que usar en
771
+ * serio: una variable de entorno la ve cualquiera con `docker inspect`, y aunque la
772
+ * invitación caduque y sea de un solo uso, no tiene por qué quedar ahí escrita.
773
+ *
774
+ * SE HACE UNA VEZ. La invitación consumida se anota y no se vuelve a intentar aunque la
775
+ * variable siga puesta: si no, cada reinicio del contenedor pediría entrar otra vez.
776
+ */
777
+ function bootstrapJoin () {
778
+ const raw = process.env.DOTRINO_JOIN_FILE
779
+ ? (() => { try { return fs.readFileSync(process.env.DOTRINO_JOIN_FILE, 'utf8') } catch (e) { console.error('[vault] could not read DOTRINO_JOIN_FILE: %s', e.message); return '' } })()
780
+ : (process.env.DOTRINO_JOIN || '')
781
+ const texto = String(raw).trim()
782
+ if (!texto) return
783
+
784
+ let qr = null
785
+ try { qr = parseInvite(texto) } catch (_) {}
786
+ if (!qr?.sn || !qr?.iss || !qr?.proxy) {
787
+ console.error('[vault] the invitation in DOTRINO_JOIN is not valid; ignoring it')
788
+ return
789
+ }
790
+ const marca = path.join(dir, 'bootstrap.json')
791
+ const hecho = readJsonSafe(marca)
792
+ if (hecho?.sn === qr.sn) return // ya se usó: un reinicio no vuelve a pedir entrar
793
+
794
+ writeJson(marca, { at: Date.now(), sn: qr.sn, iss: qr.iss })
795
+ writeJson(path.join(dir, 'join-request.json'), {
796
+ qr,
797
+ label: 'bóveda',
798
+ ...(process.env.DOTRINO_JOIN_NAME ? { name: process.env.DOTRINO_JOIN_NAME } : {})
799
+ })
800
+ console.log('[vault] joining the account of another vault (invitation from the deployment)…')
801
+ console.log('[vault] the code to type in the OTHER vault will appear below')
802
+ serve()
803
+ }
804
+ bootstrapJoin()
805
+
624
806
  console.log('[vault] servicio listo.')
625
807
  return mgr
626
808
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * keyowner.js — ESTE DIRECTORIO ES DE ESTA LLAVE, Y DE NINGUNA OTRA.
3
+ *
4
+ * Es lo único que hay que proteger (dueño, 2026-08-30): *«cada proceso tenga un directorio
5
+ * alusivo a su llave para que nunca se mezclen»*.
6
+ *
7
+ * Y lo que hay detrás es que **no hay datos compartidos**. Cada bóveda tiene su directorio
8
+ * entero —su identidad, sus perfiles, sus sobres— así que dos procesos nunca escriben el
9
+ * mismo archivo. No hace falta fusionar nada ni repartir carpetas por dentro: hace falta
10
+ * que nadie acabe, por accidente, arrancando con la identidad de otro.
11
+ *
12
+ * Cómo se accidenta uno: un `docker compose` duplicado, un `DOTRINO_VAULT_DIR` copiado de
13
+ * otro servicio, un respaldo restaurado encima. Sin esto, el proceso arranca tan tranquilo
14
+ * con una maestra que no es la suya y se identifica en el proxio como otro.
15
+ *
16
+ * La marca va EN CLARO a propósito: es una pubkey, no un secreto, y tiene que poder
17
+ * leerse antes de abrir nada.
18
+ */
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { pubkeyId } from '@dotrino/identity/capabilities'
22
+
23
+ export const OWNER_FILE = 'key.json'
24
+
25
+ /**
26
+ * EL NOMBRE DE LA CARPETA SALE DE LA LLAVE.
27
+ *
28
+ * Una llave, una carpeta (dueño, 2026-08-30): un proceso con varios perfiles tiene varias
29
+ * llaves maestras y, por tanto, varias carpetas. Con el nombre derivado de la llave eso
30
+ * deja de ser algo que se comprueba y pasa a ser imposible por construcción — dos llaves
31
+ * no pueden caer en la misma carpeta porque no se llaman igual.
32
+ *
33
+ * Empieza por la huella que se le enseña a una persona (`AB12-CD34`, la misma que sale en
34
+ * `members` y en `profile ls`), para poder mirar el disco y reconocer de quién es cada
35
+ * una; y sigue con 16 hex más, porque para *garantizar* que no chocan hacen falta más bits
36
+ * de los que caben en algo que se lee en voz alta. Quitándole los guiones, el nombre
37
+ * EMPIEZA por el mismo id que la bóveda imprime al arrancar (`listo · id 0571465f61a2fac8`),
38
+ * así que se puede casar el log con la carpeta de un vistazo.
39
+ *
40
+ * Ojo con la palabra «maestra»: cuando esta bóveda se une a la cuenta de otra (`join`), su
41
+ * llave sigue siendo la misma pero pasa a ser MIEMBRO del acta ajena, no su master. Lo que
42
+ * nombra la carpeta es la llave que esta bóveda tiene para ese perfil, que no cambia
43
+ * nunca; lo que cambia es su papel.
44
+ */
45
+ export async function keyDirName (pub) {
46
+ const h = await pubkeyId(pub) // SHA-256 del JWK canónico, 64 hex
47
+ const label = h.slice(0, 8).toUpperCase()
48
+ return `${label.slice(0, 4)}-${label.slice(4, 8)}-${h.slice(8, 24)}`
49
+ }
50
+
51
+ /** De quién es este directorio, o `null` si todavía no lo dice. */
52
+ export function keyOwnerOf (dir) {
53
+ try { return JSON.parse(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8'))?.pub || null } catch (_) { return null }
54
+ }
55
+
56
+ /**
57
+ * Comprueba que este directorio es de esta llave, o lo marca si está por estrenar.
58
+ * Lanza con `code: 'key-mismatch'` si es de otra — sin tocar nada.
59
+ */
60
+ export function assertKeyOwnsDir (dir, pub) {
61
+ if (!pub) return null // sin llave todavía: no hay nada que comparar
62
+ const previo = keyOwnerOf(dir)
63
+
64
+ if (previo && previo !== pub) {
65
+ const e = new Error(
66
+ 'this data directory belongs to a DIFFERENT identity. Two vaults must never share one: ' +
67
+ 'each process needs its own directory, or one ends up running with the other\'s master key. ' +
68
+ 'Point this one at its own DOTRINO_VAULT_DIR. Nothing was modified.'
69
+ )
70
+ e.code = 'key-mismatch'
71
+ throw e
72
+ }
73
+
74
+ if (!previo) {
75
+ try {
76
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
77
+ fs.writeFileSync(path.join(dir, OWNER_FILE), JSON.stringify({ pub, at: Date.now() }, null, 2) + '\n', { mode: 0o600 })
78
+ } catch (_) { /* si no se puede marcar, no se bloquea el arranque */ }
79
+ }
80
+ return pub
81
+ }
82
+
83
+ export default { assertKeyOwnsDir, keyOwnerOf, keyDirName, OWNER_FILE }
package/src/manager.js CHANGED
@@ -12,6 +12,8 @@ import { startVault } from './vault.js'
12
12
  import { openProfiles } from './profiles.js'
13
13
  import { installNodeGlobals } from './node-globals.js'
14
14
  import { dataDir, ensureDir } from './paths.js'
15
+ import { Identity } from '@dotrino/identity/node'
16
+ import { atRestFor } from './atrest.js'
15
17
 
16
18
  /**
17
19
  * D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
@@ -35,15 +37,40 @@ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
35
37
  throw e
36
38
  }
37
39
 
38
- export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
40
+ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, autoLockMs } = {}) {
39
41
  ensureDir(root)
40
42
  // El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
41
43
  // se instala apuntando a la RAÍZ (no al dir de un perfil) para que los perfiles
42
44
  // no se peleen por el archivo ni se lo lleven al borrarse.
43
45
  installNodeGlobals(root)
44
46
 
45
- const profiles = openProfiles(root)
46
- const migrated = profiles.migrate()
47
+ const profiles = openProfiles(root, {
48
+ ...(autoLockMs === undefined ? {} : { autoLockMs }),
49
+ // Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
50
+ // pidiendo la contraseña otra vez cree que algo se rompió.
51
+ onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
52
+ id, Math.round(profiles.autoLockMs / 60000))
53
+ })
54
+ /**
55
+ * ACUÑAR (o simplemente LEER) la llave de una carpeta, y cerrarla enseguida.
56
+ *
57
+ * Existe porque el nombre de la carpeta sale de la llave y hay que saber cuál es antes
58
+ * de poder ponérselo. La llave se genera en memoria y se escribe acto seguido —no hay
59
+ * costura entre las dos cosas en `Identity.connect`—, así que se acuña donde sea y
60
+ * después se mueve la carpeta.
61
+ *
62
+ * Se CIERRA (`destroy`) antes de devolver: quien llama va a renombrar la carpeta, y una
63
+ * identidad abierta se quedó con la ruta vieja como texto.
64
+ */
65
+ async function mintKey (dir) {
66
+ const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
67
+ try {
68
+ if (!identity.me?.publickey) await identity.setMyNickname('')
69
+ return identity.me?.publickey || null
70
+ } finally { try { identity.destroy() } catch (_) {} }
71
+ }
72
+
73
+ const migrated = await profiles.migrate(mintKey)
47
74
  if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
48
75
 
49
76
  const running = new Map() // id -> instancia de startVault
@@ -73,7 +100,15 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
73
100
  }
74
101
 
75
102
  for (const p of profiles.list()) {
76
- try { await open(p.id) } catch (e) { log('[vault] no se pudo abrir el perfil %s: %s', p.id, e.message) }
103
+ // ANTES de abrir: la carpeta tiene que llamarse como su llave. Es toda la migración
104
+ // (mover la data a la carpeta que le toca) y va aquí porque es el único momento en que
105
+ // la identidad está cerrada — renombrarla abierta la deja escribiendo en la ruta vieja.
106
+ let id = p.id
107
+ try {
108
+ id = await profiles.ensureNamedByKey(p.id, mintKey)
109
+ if (id !== p.id) log('[vault] profile %s renamed to %s (the folder is named after its key)', p.id, id)
110
+ } catch (e) { log('[vault] could not name the folder of %s after its key: %s', p.id, e.message) }
111
+ try { await open(id) } catch (e) { log('[vault] no se pudo abrir el perfil %s: %s', id, e.message) }
77
112
  }
78
113
 
79
114
  const get = (id) => {
@@ -103,8 +138,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
103
138
  * (camino A): la bóveda pone el sitio y la llave que entrará como miembro, pero la
104
139
  * cuenta la trae el dispositivo y se adopta al emparejar.
105
140
  */
106
- async add (name, { adopt = false } = {}) {
107
- const p = profiles.add(name, { adopt })
141
+ async add (name, { adopt = false, kek = null } = {}) {
142
+ const p = await profiles.add(name, { adopt, kek, mintKey })
108
143
  await open(p.id)
109
144
  log('[vault] perfil creado%s: %s (%s)', adopt ? ' (a la espera de adoptar una cuenta)' : '', p.name || '(sin nombre)', p.id)
110
145
  return profiles.get(p.id)
package/src/passwords.js CHANGED
@@ -27,7 +27,8 @@ export const PASSWORDS_CAP = 'passwords'
27
27
  * `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
28
28
  * `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
29
29
  * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
30
- * `needsApproval(pubkey)` si ese aparato está marcado para aprobar
30
+ * `needsApproval(pubkey)` si ese aparato está marcado para aprobar. Se compone con el
31
+ * criterio del propio protocolo: solo lo PRIVADO pregunta
31
32
  * `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
32
33
  * `devices()` / `unlink(pubkey)` administración, opcional
33
34
  * `audit(op, info)` la bitácora del vault
@@ -53,9 +54,19 @@ export function createPasswordDesk (opts = {}) {
53
54
  vault,
54
55
  isAllowed,
55
56
  encPubOf,
56
- // La aprobación es del APARATO y dura mientras el vault siga encendido, igual que
57
- // los cajones de secretos: una por arranque, sin ventana que vigilar.
58
- needsApproval: (op, _payload, pubkey) => op === 'get' && needsApproval(pubkey),
57
+ // Dos condiciones, y las dos tienen que darse:
58
+ //
59
+ // · que ese APARATO esté marcado para aprobar (la política del vault: se pide una
60
+ // vez y dura mientras el vault siga encendido, igual que los cajones de secretos);
61
+ // · y que lo que pide sea PRIVADO — una contraseña, un código de dos pasos, o un
62
+ // campo que el usuario marcó. Rellenar un nombre no es sacar un secreto, y pedir
63
+ // permiso para todo enseña a decir que sí sin mirar.
64
+ //
65
+ // El segundo criterio se toma del propio responder (`wantsPrivate`) en vez de
66
+ // reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
67
+ // bóvedas distintas otra vez.
68
+ needsApproval: async (op, payload, pubkey) =>
69
+ needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
59
70
  approve,
60
71
  admin: devices && unlink ? { devices, unlink } : null,
61
72
  onRequest: (r) => {