@dotrino/vaultd 0.56.0 → 0.59.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
@@ -277,7 +277,12 @@ export function createEnrollDesk ({
277
277
  pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
278
278
  }
279
279
  pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
280
- if (d.label) pend.label = String(d.label).slice(0, 60)
280
+ // EL NOMBRE QUE PUSISTE AQUÍ MANDA. El aparato manda el suyo al enrolarse, y hasta
281
+ // ahora pisaba siempre al de la bóveda — como el aparato usa por defecto el apodo del
282
+ // PERFIL, acababas con varios dispositivos llamados igual que tú y sin forma de saber
283
+ // cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
284
+ // sigue valiendo como propuesta cuando no dijiste nada.
285
+ if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
281
286
  // Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
282
287
  // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
283
288
  if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
@@ -359,7 +364,7 @@ export function createEnrollDesk ({
359
364
  pend.state = 'DONE'
360
365
  pending.delete(pend.token)
361
366
  fire(onPendingChange)
362
- log('[vault] device approved: %s', pend.deviceId)
367
+ log(`[vault] device approved: ${pend.deviceId}`)
363
368
  return { ok: true, deviceId: pend.deviceId, cert }
364
369
  }
365
370
 
@@ -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.59.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
  /**
@@ -191,6 +205,17 @@ async function cmdPair (args = []) {
191
205
  // --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
192
206
  // claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
193
207
  const approval = args.includes('--approval')
208
+ // --name <n>: CÓMO SE VA A LLAMAR el aparato que entre, decidido aquí y antes de nada.
209
+ // Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
210
+ // PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
211
+ // había que renombrarlos después —cuando ya no sabías cuál era cuál—.
212
+ const nIdx = args.indexOf('--name')
213
+ let label = null
214
+ if (nIdx >= 0) {
215
+ const v = args[nIdx + 1]
216
+ if (!v || v.startsWith('-')) { console.error('uso: dotrino-vault pair --name "teléfono de casa"'); process.exit(2) }
217
+ label = v.slice(0, 60)
218
+ }
194
219
  // --admin: el aparato que entre por esta invitación podrá ADMINISTRAR (la consola).
195
220
  // El QR no lleva nada: es una nota local de la bóveda y el permiso se aplica al aprobar
196
221
  // con el código que tecleas aquí. Ahorra el `caps <ID> +administra` de después, que en un
@@ -244,9 +269,11 @@ async function cmdPair (args = []) {
244
269
  // meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
245
270
  // entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
246
271
  // opciones; en la CLI es una bandera, para que siga sirviendo en un script.
272
+ // `--name` lleva su valor detrás: no puede confundirse con el nombre de la cuenta.
273
+ const consumido = (i) => nIdx >= 0 && i === nIdx + 1
247
274
  const naIdx = args.findIndex((a) => a === '--new-account')
248
275
  if (naIdx >= 0) {
249
- const next = args[naIdx + 1]
276
+ const next = consumido(naIdx + 1) ? null : args[naIdx + 1]
250
277
  const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
251
278
  const d = await profileRequest('add', { name, ...(pairKek ? { kek: pairKek } : {}) })
252
279
  if (d.error) { console.error('%s', d.error); process.exit(1) }
@@ -261,7 +288,7 @@ async function cmdPair (args = []) {
261
288
  const adIdx = args.findIndex((a) => a === '--adopt')
262
289
  const adopt = adIdx >= 0
263
290
  if (adopt) {
264
- const next = args[adIdx + 1]
291
+ const next = consumido(adIdx + 1) ? null : args[adIdx + 1]
265
292
  const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
266
293
  const d = await profileRequest('add', { name, adopt: true, ...(pairKek ? { kek: pairKek } : {}) })
267
294
  if (d.error) { console.error('%s', d.error); process.exit(1) }
@@ -284,7 +311,7 @@ async function cmdPair (args = []) {
284
311
 
285
312
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
286
313
  // se empareja el dispositivo.
287
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}) })
314
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
288
315
  sendSignal(s.pid, 'SIGUSR1')
289
316
 
290
317
  let pair = null
@@ -505,7 +532,14 @@ async function cmdMembers () {
505
532
  assertOpen(record)
506
533
  if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
507
534
 
508
- const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}` }
535
+ // `sealer` y `passwords` faltaban y salían crudos, en inglés, entre los demás en
536
+ // español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
537
+ // puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
538
+ const CAP = {
539
+ sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
540
+ admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
541
+ sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
542
+ }
509
543
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
510
544
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
511
545
  // misma que se enseña al emparejar y en la lista de miembros.
@@ -519,7 +553,13 @@ async function cmdMembers () {
519
553
  m.cn ? `servicio «${m.cn}»` : null
520
554
  ].filter(Boolean)
521
555
  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)
556
+ // CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
557
+ // distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
558
+ // es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
559
+ // conectado. El acta ya la guardaba (`addedAt`); solo no se enseñaba.
560
+ console.log(' %s %s%s%s\n %s', m.id, who,
561
+ m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
562
+ marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
523
563
  // Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
524
564
  // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
525
565
  // se mira quién es quién — y en la lista de variables ya seria tarde.
@@ -1442,6 +1482,9 @@ function help () {
1442
1482
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
1443
1483
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1444
1484
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1485
+ pair --name <nombre> cómo se llamará el aparato que entre. Sin esto el nombre lo pone
1486
+ ÉL, y por defecto usa el apodo del perfil: acabas con varios
1487
+ dispositivos llamados igual que tú
1445
1488
  pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1446
1489
  pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
1447
1490
  El QR no lleva nada: el permiso se aplica al aprobar el código aquí
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 }
package/src/tui/app.js CHANGED
@@ -918,12 +918,41 @@ async function descartarCuenta (term, st, id, volverA) {
918
918
  if (r.ok) flash(st, i.accountDiscarded)
919
919
  }
920
920
 
921
+ /**
922
+ * PRIMERO EL NOMBRE, y luego el QR.
923
+ *
924
+ * Sin preguntarlo, el nombre lo pone el propio aparato al enrolarse, y por defecto usa el
925
+ * apodo del PERFIL: te quedaban tres dispositivos llamados igual que tú y ninguna forma de
926
+ * saber cuál era cuál sin renombrarlos después —cuando ya no te acordabas—. Se pregunta
927
+ * ANTES porque después ya estás mirando un QR con el teléfono en la mano.
928
+ *
929
+ * Enter en blanco sigue valiendo: deja que lo diga el aparato, que es el comportamiento de
930
+ * siempre para quien no quiera decidirlo.
931
+ */
932
+ function beginPairingNamed (term, st, profile, service = null, after = null) {
933
+ const i = L(st)
934
+ setInput(st, {
935
+ label: i.nameDeviceLabel,
936
+ hint: i.nameDeviceHint,
937
+ onSubmit: async (nombre) => {
938
+ st.input = null
939
+ const ok = await beginPairing(term, st, profile, service, (nombre || '').trim().slice(0, 60) || null)
940
+ // `after` es para quien tenga que limpiar si el emparejamiento no llega a abrirse
941
+ // (la cuenta que nació para él). Va aquí porque con el nombre de por medio ya no se
942
+ // puede mirar el valor de retorno desde fuera.
943
+ if (after) await after(ok)
944
+ },
945
+ onCancel: () => { st.input = null }
946
+ })
947
+ return true
948
+ }
949
+
921
950
  /**
922
951
  * Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
923
952
  * el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
924
953
  */
925
- async function beginPairing (term, st, profile, service = null) {
926
- const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}) }))
954
+ async function beginPairing (term, st, profile, service = null, label = null) {
955
+ const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}), ...(label ? { label } : {}) }))
927
956
  // `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
928
957
  // tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
929
958
  // que una máquina que solo va a leer la configuración del proxy.
@@ -983,7 +1012,7 @@ async function onKeyPairMode (term, st, key) {
983
1012
  if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; return true }
984
1013
  if (key.name !== 'enter' || !cur) return true
985
1014
 
986
- if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
1015
+ if (cur.mode === 'here') { return beginPairingNamed(term, st, activeId(st)) }
987
1016
 
988
1017
  // SERVICIO: entra a la cuenta activa, pero con un certificado que solo sirve para
989
1018
  // pedir las variables de SU namespace. El nombre del ns es el que luego pide el
@@ -1025,8 +1054,10 @@ async function onKeyPairMode (term, st, key) {
1025
1054
  // Si el emparejamiento no llega a abrirse, la cuenta que se creó PARA él se va con
1026
1055
  // él: si no, cada intento fallido dejaba una cuenta vacía —y encima activa— que
1027
1056
  // luego había que ir a borrar a mano adivinando cuál era.
1028
- if (!await beginPairing(term, st, created)) await descartarCuenta(term, st, created, previa)
1029
- else st.pairing.born = { id: created, from: previa }
1057
+ beginPairingNamed(term, st, created, null, async (ok) => {
1058
+ if (!ok) await descartarCuenta(term, st, created, previa)
1059
+ else st.pairing.born = { id: created, from: previa }
1060
+ })
1030
1061
  },
1031
1062
  onCancel: () => { st.input = null }
1032
1063
  })
package/src/tui/i18n.js CHANGED
@@ -140,6 +140,8 @@ const es = {
140
140
  cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
141
141
  revokedCount: (n) => ` Revocados: ${n}`,
142
142
  startingPairing: 'Iniciando emparejamiento…',
143
+ nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
144
+ nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
143
145
  noPending: 'No hay ningún dispositivo pendiente',
144
146
  noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
145
147
  rejecting: 'Rechazando…',
@@ -435,6 +437,8 @@ const en = {
435
437
  cantRemoveMaster: 'This vault is the Master: it does not remove itself.',
436
438
  revokedCount: (n) => ` Revoked: ${n}`,
437
439
  startingPairing: 'Starting pairing…',
440
+ nameDeviceLabel: 'What will this device be called?',
441
+ nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
438
442
  noPending: 'No device is waiting',
439
443
  noPendingToReject: 'No device is waiting to be rejected',
440
444
  rejecting: 'Rejecting…',
@@ -604,10 +604,10 @@ export function pairUrl (qr) {
604
604
  return { url: inviteUrl(qr), code, payload: JSON.stringify(qr), b64: code }
605
605
  }
606
606
 
607
- export async function startPairing ({ profile, service } = {}) {
607
+ export async function startPairing ({ profile, service, label } = {}) {
608
608
  requireAlive()
609
609
  rm(F.pair); rm(F.pending)
610
- await writeReq(F.pairReq, service ? { service } : {}, profile)
610
+ await writeReq(F.pairReq, { ...(service ? { service } : {}), ...(label ? { label } : {}) }, profile)
611
611
  signalOrCleanup('SIGUSR1', [F.pairReq])
612
612
  for (let i = 0; i < 50; i++) {
613
613
  await sleep(100)