@dotrino/vaultd 0.56.0 → 0.58.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/lib/src/enroll.js CHANGED
@@ -359,7 +359,7 @@ export function createEnrollDesk ({
359
359
  pend.state = 'DONE'
360
360
  pending.delete(pend.token)
361
361
  fire(onPendingChange)
362
- log('[vault] device approved: %s', pend.deviceId)
362
+ log(`[vault] device approved: ${pend.deviceId}`)
363
363
  return { ok: true, deviceId: pend.deviceId, cert }
364
364
  }
365
365
 
@@ -23,8 +23,7 @@ import path from 'node:path'
23
23
  import { createHash } from 'node:crypto'
24
24
  import {
25
25
  makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
26
- verifyDeviceSig, makePairingCode, commitCode, pubkeyId
27
- } from '@dotrino/identity/capabilities'
26
+ verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
28
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
29
28
  import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
30
29
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
@@ -298,7 +297,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
298
297
  const res = await enrolled
299
298
 
300
299
  if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
301
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
300
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, ...(expectedScope ? { expectedScope } : {}) })
302
301
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
303
302
  if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
304
303
 
@@ -440,7 +439,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
440
439
  client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
441
440
  const res = await pending
442
441
  if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
443
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
442
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, expectedScope: secretsScope(ns) })
444
443
  if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
445
444
  }
446
445
  } catch (_) { /* la renovación no bloquea el fetch */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.56.0",
3
+ "version": "0.58.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.63.0",
23
+ "@dotrino/identity": "^0.64.0",
24
24
  "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/client.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
12
12
  * guardarlo. La maestra nunca sale del vault.
13
13
  */
14
- import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
14
+ import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
15
15
  import { installNodeGlobals } from './node-globals.js'
16
16
  import { MSG } from './protocol.js'
17
17
 
@@ -84,7 +84,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
84
84
  const res = await enrolled
85
85
 
86
86
  // VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
87
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
87
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
88
88
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
89
89
  if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
90
90
  if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
@@ -151,7 +151,7 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
151
151
  if (res.type === MSG.ERROR) throw new Error(res.error)
152
152
  // Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
153
153
  // firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
154
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
154
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
155
155
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
156
156
  if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
157
157
  return { cert: res.cert }
package/src/ctl.js CHANGED
@@ -68,6 +68,20 @@ const writeReq = (name, obj) => {
68
68
  }
69
69
 
70
70
  const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
71
+ const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el nombre
72
+
73
+ /**
74
+ * La fecha en que entró un aparato, corta y en local. Sin hora: lo que se busca al mirar
75
+ * esta lista es «¿de cuándo es este?», no el minuto exacto — y una fecha larga en cada
76
+ * línea convierte la lista en un muro.
77
+ *
78
+ * En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
79
+ * en medio de una frase en español, que es peor que cualquiera de los dos por separado.
80
+ */
81
+ function fechaCorta (ms) {
82
+ try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
83
+ catch (_) { return '' }
84
+ }
71
85
  // La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
72
86
 
73
87
  /**
@@ -505,7 +519,14 @@ async function cmdMembers () {
505
519
  assertOpen(record)
506
520
  if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
507
521
 
508
- const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}` }
522
+ // `sealer` y `passwords` faltaban y salían crudos, en inglés, entre los demás en
523
+ // español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
524
+ // puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
525
+ const CAP = {
526
+ sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
527
+ admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
528
+ sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
529
+ }
509
530
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
510
531
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
511
532
  // misma que se enseña al emparejar y en la lista de miembros.
@@ -519,7 +540,13 @@ async function cmdMembers () {
519
540
  m.cn ? `servicio «${m.cn}»` : null
520
541
  ].filter(Boolean)
521
542
  const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
522
- console.log(' %s %s%s\n %s', m.id, who, marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
543
+ // CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
544
+ // distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
545
+ // es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
546
+ // conectado. El acta ya la guardaba (`addedAt`); solo no se enseñaba.
547
+ console.log(' %s %s%s%s\n %s', m.id, who,
548
+ m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
549
+ marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
523
550
  // Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
524
551
  // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
525
552
  // se mira quién es quién — y en la lista de variables ya seria tarde.
package/src/daemon.js CHANGED
@@ -20,6 +20,7 @@ import { takeLock } from '../lib/src/lock.js'
20
20
  import { startVaultManager } from './manager.js'
21
21
  import { dataDir, writeJson, readJson } from './paths.js'
22
22
  import { parseInvite } from '../lib/src/invite.js'
23
+ import { watchBinary } from './selfupdate.js'
23
24
  import { VERSION } from './version.js'
24
25
 
25
26
  const readJsonSafe = (f) => readJson(f, null)
@@ -742,7 +743,7 @@ export async function runDaemon () {
742
743
 
743
744
  // --- apagado limpio ---
744
745
  const shutdown = (sig) => {
745
- console.log(`\n[vault] ${sig} → deteniendo…`)
746
+ console.log(`\n[vault] ${sig} → stopping…`)
746
747
  rm(pairFile); rm(pendingEnrollFile)
747
748
  try { mgr.close() } catch (_) {}
748
749
  process.exit(0)
@@ -803,6 +804,16 @@ export async function runDaemon () {
803
804
  }
804
805
  bootstrapJoin()
805
806
 
807
+ // ACTUALIZAR EL PAQUETE = ACTUALIZAR EL SERVICIO. El instalador no puede reiniciarnos
808
+ // (somos un servicio de USUARIO y él corre como root), así que lo hacemos nosotros: si
809
+ // el binario cambia en el disco, nos vamos y systemd nos levanta con el nuevo. Solo bajo
810
+ // systemd; ver `src/selfupdate.js` para por qué eso no es negociable.
811
+ const vigia = watchBinary({
812
+ log: console.log,
813
+ exit: () => shutdown('new binary installed')
814
+ })
815
+ if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
816
+
806
817
  console.log('[vault] servicio listo.')
807
818
  return mgr
808
819
  }
package/src/profiles.js CHANGED
@@ -222,6 +222,10 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
222
222
  // la llave— es largo, y nadie va a teclear 24 caracteres para decir `--profile`. El
223
223
  // trozo que sí se lee y se reconoce es la huella de delante (`0571-465F`), que es la
224
224
  // misma que sale en `members`. Ambiguo se rechaza, no se adivina.
225
+ // El id que tenía antes de que la carpeta se llamara como su llave (ver
226
+ // `ensureNamedByKey`): un cliente abierto durante la actualización sigue usándolo.
227
+ const viejo = data.profiles.find((p) => p.wasId && p.wasId.toLowerCase() === needle)
228
+ if (viejo) return viejo.id
225
229
  const porPrefijo = data.profiles.filter((p) => p.id.toLowerCase().startsWith(needle))
226
230
  if (porPrefijo.length === 1) return porPrefijo[0].id
227
231
  if (porPrefijo.length > 1) throw new Error(`"${ref}" matches ${porPrefijo.length} profiles; give more of the id (dotrino-vault profile ls)`)
@@ -380,7 +384,15 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
380
384
  }
381
385
  fs.renameSync(dir, dirOf(quiere))
382
386
  const p = find(id)
383
- if (p) p.id = quiere
387
+ if (p) {
388
+ p.id = quiere
389
+ // EL NOMBRE VIEJO SE RECUERDA. Quien tenía una consola o una TUI abierta durante
390
+ // la actualización sigue pidiendo por el id de antes, y sin esto cada petición
391
+ // suya contestaba «ese perfil no existe» —que es verdad y no ayuda nada—. Se
392
+ // traduce en `resolve`, así que el cliente viejo sigue funcionando hasta que se
393
+ // reinicie, que es cuando dejará de preguntarlo.
394
+ p.wasId = id
395
+ }
384
396
  if (data.current === id) data.current = quiere
385
397
  if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
386
398
  save()
@@ -0,0 +1,86 @@
1
+ /**
2
+ * selfupdate.js — ACTUALIZAR EL PAQUETE TENÍA QUE SIGNIFICAR ACTUALIZAR EL SERVICIO.
3
+ *
4
+ * No lo significaba, y el resultado era una bóveda que decía correr una versión y corría
5
+ * otra. Pasó de verdad (dueño, 2026-08-31): el `.deb` puso 0.56.0 en el disco y el daemon
6
+ * siguió ocho versiones atrás durante días, sin que nada fallara de forma visible —
7
+ * `status` lo avisaba en una línea que nadie mira, y el aviso del instalador se pierde
8
+ * entre el resto de la salida.
9
+ *
10
+ * POR QUÉ EL INSTALADOR NO PUEDE HACERLO. La unidad es de USUARIO (`systemctl --user`) y
11
+ * el `postinst` del paquete corre como root. Root no tiene la sesión del usuario: para
12
+ * reiniciarle el servicio tendría que averiguar qué usuarios lo tienen puesto, buscar su
13
+ * `XDG_RUNTIME_DIR` y entrar en su bus. Es frágil, y con varios usuarios en la máquina es
14
+ * además decidir por ellos. Por eso el instalador solo lo dice.
15
+ *
16
+ * ASÍ QUE LO HACE EL DAEMON. Mira su propio binario y, cuando cambia, se va limpiamente;
17
+ * systemd lo levanta otra vez (`Restart=always`) y esa vez arranca el nuevo. Es la única
18
+ * vía que no necesita root, funciona con cualquier número de usuarios y no depende de que
19
+ * nadie se acuerde de nada.
20
+ *
21
+ * LAS DOS CONDICIONES, y ninguna es opcional:
22
+ *
23
+ * 1. **Que haya quien nos levante.** Si nadie nos reinicia, irse es apagar la bóveda del
24
+ * usuario para siempre — el peor final posible para una mejora de comodidad. Se exige
25
+ * `INVOCATION_ID`, que systemd pone en el entorno del servicio: sin eso, no se toca
26
+ * nada. Un `npx`, un contenedor o un arranque a mano quedan fuera a propósito.
27
+ * 2. **Que el archivo esté quieto.** Un `.deb` a medio escribir cambia de tamaño varias
28
+ * veces; irse en ese instante deja a systemd arrancando un binario truncado. Por eso
29
+ * la huella nueva tiene que verse en DOS pasadas seguidas: si aún se está copiando,
30
+ * la segunda no coincide y se vuelve a esperar.
31
+ */
32
+ import fs from 'node:fs'
33
+ import path from 'node:path'
34
+
35
+ /**
36
+ * Cada cuánto se mira. No hay prisa: un minuto de retraso en actualizar no le duele a
37
+ * nadie, y este mismo plazo hace de espera — la huella nueva tiene que verse DOS veces
38
+ * seguidas, así que un archivo a medio escribir no llega a contar.
39
+ */
40
+ export const CHECK_MS = 60_000
41
+
42
+ /** Huella barata del archivo: tamaño + fecha. No hace falta leerlo entero (son 125 MB). */
43
+ function fingerprint (file) {
44
+ try { const s = fs.statSync(file); return `${s.size}:${Math.round(s.mtimeMs)}` } catch (_) { return null }
45
+ }
46
+
47
+ /**
48
+ * ¿Estamos corriendo el binario autosuficiente, bajo un systemd que nos va a levantar?
49
+ *
50
+ * `process.execPath` es el binario cuando es un SEA, y es `node` cuando se corre desde el
51
+ * repo o por npx. En ese segundo caso vigilar no tiene sentido: actualizar el paquete no
52
+ * cambia `node`, y lo que habría que mirar es otra cosa.
53
+ */
54
+ export function shouldWatch (env = process.env, execPath = process.execPath) {
55
+ if (!env.INVOCATION_ID) return false // nadie garantiza el reinicio
56
+ return /dotrino-vaultd/.test(path.basename(execPath)) // el binario, no `node`
57
+ }
58
+
59
+ /**
60
+ * Vigila el binario propio y termina el proceso cuando cambie.
61
+ * @returns {{stop:()=>void}|null} `null` si no procede vigilar.
62
+ */
63
+ export function watchBinary ({ log = console.log, exit = (c) => process.exit(c), env = process.env, execPath = process.execPath, checkMs = CHECK_MS } = {}) {
64
+ if (!shouldWatch(env, execPath)) return null
65
+ const inicial = fingerprint(execPath)
66
+ if (!inicial) return null
67
+
68
+ let quieto = null // huella del candidato, para exigir que se repita
69
+
70
+ const t = setInterval(() => {
71
+ const ahora = fingerprint(execPath)
72
+ if (!ahora || ahora === inicial) { quieto = null; return }
73
+ if (quieto !== ahora) { quieto = ahora; return } // primera vez que se ve: esperar
74
+ clearInterval(t)
75
+ log('[vault] the binary changed on disk: restarting to run the new version')
76
+ // Se sale con 0 y limpiamente: systemd lo trata como una parada normal y vuelve a
77
+ // arrancar por `Restart=always`. El apagado del daemon ya suelta el candado y cierra
78
+ // el transporte, así que la bóveda no queda ni con el candado puesto ni a medias.
79
+ exit(0)
80
+ }, Math.max(1000, checkMs))
81
+ t.unref?.()
82
+
83
+ return { stop: () => clearInterval(t) }
84
+ }
85
+
86
+ export default { watchBinary, shouldWatch, CHECK_MS }