@dotrino/vaultd 0.26.2 → 0.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.js CHANGED
@@ -36,7 +36,7 @@ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
36
36
  * pid muerto (se cortó la luz) no estorba. Con `DOTRINO_VAULT_DIR` distintos conviven
37
37
  * cuantas quieras: lo que colisiona es el directorio, no el programa.
38
38
  */
39
- function comprobarInstanciaUnica (dir) {
39
+ function assertSingleInstance (dir) {
40
40
  let s = null
41
41
  try { s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')) } catch (_) { return }
42
42
  const pid = Number(s?.pid)
@@ -52,7 +52,7 @@ function comprobarInstanciaUnica (dir) {
52
52
  export async function runDaemon () {
53
53
  const dir = dataDir()
54
54
  const proxyUrl = process.env.PROXY_URL || 'wss://proxy.dotrino.com'
55
- comprobarInstanciaUnica(dir)
55
+ assertSingleInstance(dir)
56
56
 
57
57
  const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
58
58
  // Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
@@ -79,26 +79,47 @@ export async function runDaemon () {
79
79
  }
80
80
  writeState()
81
81
  const profilesFile = path.join(dir, 'profiles-list.json')
82
- const dumpProfiles = (extra = {}) => { writeState(); writeJson(profilesFile, { v: 1, at: Date.now(), current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
82
+ // `req: null` VA SIEMPRE, aunque nadie haya pedido nada: el daemon vuelca esta lista
83
+ // también por su cuenta (cada repaso, y al atender cualquier otra cosa), y quien espera
84
+ // respuesta tiene que poder distinguir «este volcado no contesta a nadie» de «este
85
+ // contesta a lo mío». Sin esa marca, un repaso que caía en medio pasaba por respuesta:
86
+ // el `unlock` se daba por hecho con la foto de cuando aún estaba cerrada, y un
87
+ // «contraseña incorrecta» se perdía por el camino sin que nadie lo llegara a ver.
88
+ const dumpProfiles = (extra = {}) => { writeState(); writeJson(profilesFile, { v: 1, at: Date.now(), req: null, current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
83
89
 
84
90
  console.log(`dotrino-vault · datos en ${dir} · proxy ${proxyUrl}`)
85
91
  for (const p of mgr.summary()) {
86
92
  console.log(`perfil ${p.current ? '*' : ' '} ${p.name || '(sin nombre)'} · ${p.id} · ${p.fingerprint}${p.protected ? (p.locked ? ' · 🔒 bloqueado' : ' · 🔓 desbloqueado') : ''}`)
87
93
  }
88
94
 
89
- /** Perfil destino de una petición de la CLI (o el activo si no lo dice). */
95
+ /**
96
+ * Perfil destino de una petición de la CLI (o el activo si no lo dice), con su CANDADO.
97
+ *
98
+ * EL CANDADO ES DE ESTA CONSOLA. Un perfil con contraseña y bloqueado no se puede ver ni
99
+ * tocar desde la máquina de la bóveda —ni la lista de aparatos, ni las variables, ni tus
100
+ * datos, ni emparejar o quitar nada— hasta que alguien teclee la contraseña
101
+ * (`dotrino-vault unlock`). Lo que NO cambia es el servicio: los aparatos ya emparejados
102
+ * siguen firmando, leyendo y guardando, porque la bóveda es de ellos tanto como de esta
103
+ * pantalla. Lo que se protege es la consola, que es donde se administra y donde se mira.
104
+ */
90
105
  const resolveTarget = (req) => {
91
106
  try {
92
107
  const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
93
- return { id, vault: mgr.get(id) }
108
+ return { id, vault: mgr.get(id), locked: mgr.profiles.isLocked(id) }
94
109
  } catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
95
110
  }
96
- const targetOf = (req) => resolveTarget(req)?.vault || null
111
+ /** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
112
+ const targetOf = (req) => {
113
+ const t = resolveTarget(req)
114
+ if (!t) return null
115
+ if (t.locked) { console.error('[vault] profile %s is locked: request refused (unlock it to use this console)', t.id); return null }
116
+ return t.vault
117
+ }
97
118
 
98
119
  // --- SIGUSR1: iniciar emparejamiento ---
99
120
  const pairFile = path.join(dir, 'pair.json')
100
121
  const pairReqFile = path.join(dir, 'pair-request.json')
101
- async function atenderEmparejamiento () {
122
+ async function handlePairingRequest () {
102
123
  try {
103
124
  rm(pendingEnrollFile)
104
125
  // Pairing manual por CLI = gesto explícito del dueño → cert de identidad completo.
@@ -111,12 +132,31 @@ export async function runDaemon () {
111
132
  // vault:secrets:<ns> — para enrolar un SERVICIO (proxy, geo…) que lee sus
112
133
  // secretos, sin poder firmar como el usuario ni leer sus datos.
113
134
  const pairReq = readJsonSafe(pairReqFile); rm(pairReqFile)
135
+ const locked = resolveTarget(pairReq)
136
+ if (locked?.locked) {
137
+ // Se responde por el MISMO archivo que espera quien pidió el QR: si no, se queda
138
+ // mirando una pantalla vacía hasta que se agote el tiempo, sin saber por qué.
139
+ writeJson(pairFile, { v: 2, at: Date.now(), profile: locked.id, locked: true })
140
+ return console.error('[vault] profile %s is locked: pairing refused', locked.id)
141
+ }
114
142
  const vault = targetOf(pairReq)
115
143
  if (!vault) return
116
144
  const profileId = pairReq?.profile ? mgr.resolve(pairReq.profile) : mgr.currentId()
117
145
  const isService = typeof pairReq?.service === 'string' && pairReq.service
118
- const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
119
- const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
146
+ // PERMISOS, no tipos (2026-08-22): el cert lleva lo que pidió `pair --scope`, y si
147
+ // no pidió nada, el juego de siempre. `--service <ns>` sigue siendo el atajo de
148
+ // `vault:secrets:<ns>`. Se valida aquí porque es la maestra la que firma: nada
149
+ // que no esté en esta lista entra en un cert, y `vault:admin` nunca por este camino.
150
+ const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
151
+ const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
152
+ if (asked && asked.some((x) => !ALLOWED(x))) {
153
+ writeJson(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
154
+ return console.error('[vault] pairing refused: scope not allowed (%s)', asked.join(','))
155
+ }
156
+ const scope = asked?.length
157
+ ? [...new Set(asked)]
158
+ : isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
159
+ const label = pairReq?.label || (isService ? 'service:' + pairReq.service : 'cli')
120
160
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
121
161
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
122
162
  // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
@@ -131,7 +171,7 @@ export async function runDaemon () {
131
171
  // crea uno vacío), o no habría dónde meterla.
132
172
  const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
133
173
  const { qr, expiresInMs } = await vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
134
- writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
174
+ writeJson(pairFile, { v: 2, at: Date.now(), qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
135
175
  // El token es un secreto efímero: no debe quedar en disco más allá de su
136
176
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
137
177
  const tok = qr.token
@@ -148,10 +188,26 @@ export async function runDaemon () {
148
188
  // --- SIGUSR2: approve / reject / revoke / secretos / perfiles + volcados ---
149
189
  const devFile = path.join(dir, 'devices.json')
150
190
  const approveReqFile = path.join(dir, 'approve-request.json')
191
+ const approveFile = path.join(dir, 'approve.json')
151
192
  const rejectReqFile = path.join(dir, 'reject-request.json')
152
193
  const revokeReqFile = path.join(dir, 'revoke-request.json')
153
194
  const secretReqFile = path.join(dir, 'secret-request.json')
154
195
  const secretsListFile = path.join(dir, 'secrets-list.json')
196
+ /**
197
+ * Por qué falló la última orden de variables. La consola pide el cambio y el volcado
198
+ * por señales distintas, así que el motivo no cabe en la respuesta: se guarda aquí y
199
+ * viaja en el volcado siguiente. Sin esto, una contraseña equivocada se leía como
200
+ * «El daemon no aplicó el cambio», que no dice qué hacer.
201
+ */
202
+ let lastSecretError = null
203
+ /**
204
+ * El VALOR que se acaba de destapar, esperando al volcado siguiente (mismo camino que
205
+ * `lastSecretError`: la orden y el volcado son señales distintas). Vive en memoria un
206
+ * instante y se va con el volcado — quien lo lee borra el archivo enseguida.
207
+ */
208
+ let lastSecretValue = null
209
+ /** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
210
+ let lastSecretHistory = null
155
211
  const profileReqFile = path.join(dir, 'profile-request.json')
156
212
  const dumpReqFile = path.join(dir, 'dump-request.json')
157
213
  const meReqFile = path.join(dir, 'me-request.json')
@@ -171,6 +227,19 @@ export async function runDaemon () {
171
227
  * 0700 del vault y se BORRA al consumirla — mismo camino que ya usan los
172
228
  * secretos, y así nunca pasa por `ps` ni por el historial de la shell.
173
229
  */
230
+ /**
231
+ * Vuelve a cerrar la copia maestra de los secretos con otra llave. Se llama al poner
232
+ * o quitar la contraseña del perfil: los sobres de las variables NO se tocan (siguen
233
+ * sellados a la llave de cada aparato), solo cambia con qué se abre el llavero de
234
+ * administración. Sin esto, cambiar la contraseña dejaría los secretos ilegibles.
235
+ */
236
+ async function rekey (id, vieja, nueva) {
237
+ const v = mgr.get(id)
238
+ if (!v?.rekeySecrets) return
239
+ const r = await v.rekeySecrets(vieja, nueva)
240
+ if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
241
+ }
242
+
174
243
  async function handleProfileRequest (req) {
175
244
  const ref = () => mgr.resolve(req.profile || mgr.currentId())
176
245
  switch (req.op) {
@@ -181,23 +250,79 @@ export async function runDaemon () {
181
250
  case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
182
251
  case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
183
252
  case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
184
- case 'unlock': { await mgr.profiles.unlock(ref(), req.password); return { done: 'perfil desbloqueado' } }
253
+ // ABRIR LA BÓVEDA SALDA LO QUE SE DEBE. Un aparato que entró después de escrita
254
+ // una variable no puede abrirla —envolverle su llave exige abrir la CEK, y eso
255
+ // pide la frase—, así que se queda en deuda y se ve en la consola y en la TUI.
256
+ // Este es el único momento en que la frase está delante, así que es aquí donde se
257
+ // paga; el dueño no tiene que acordarse de un comando aparte.
258
+ case 'unlock': {
259
+ const id = ref()
260
+ await mgr.profiles.unlock(id, req.password)
261
+ let note = ''
262
+ try {
263
+ const ak = await mgr.profiles.adminKey(id, req.password)
264
+ // REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
265
+ // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
266
+ // reemplazando lo que alguien metiera mal y quitando lo que sobre.
267
+ const r = await mgr.get(id)?.resealAll?.(ak)
268
+ if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s',
269
+ r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '')
270
+ if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
271
+ else if (r?.wrapped) note = ' · llavero al día'
272
+ } catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
273
+ return { done: 'perfil desbloqueado' + note }
274
+ }
185
275
  case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
186
- case 'password-set': { await mgr.profiles.setPassword(ref(), req.password); return { done: 'contraseña guardada' } }
187
- case 'password-rm': { mgr.profiles.removePassword(ref()); return { done: 'contraseña quitada' } }
276
+ // PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
277
+ // abrirse con la frase. Hay que volver a cerrar la copia maestra con la nueva, o
278
+ // quedarían ilegibles. Si el perfil YA tenía contraseña, hace falta la vieja para
279
+ // poder abrirla: por eso el camino normal para cambiarla es quitarla y ponerla.
280
+ case 'password-set': {
281
+ const id = ref()
282
+ const tenia = !!mgr.profiles.get(id)?.protected
283
+ if (tenia && !req.current) throw new Error('this profile already has a password: remove it first (`profile password --rm`) and then set the new one')
284
+ const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
285
+ await mgr.profiles.setPassword(id, req.password)
286
+ const nueva = await mgr.profiles.adminKey(id, req.password)
287
+ await rekey(id, vieja, nueva)
288
+ return { done: 'contraseña guardada' }
289
+ }
290
+ // QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
291
+ // con la llave de la máquina, que es la protección de siempre — el disco sigue
292
+ // cifrado, pero su material vive en ese mismo disco.
293
+ case 'password-rm': {
294
+ const id = ref()
295
+ if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
296
+ const vieja = await mgr.profiles.adminKey(id, req.password)
297
+ await rekey(id, vieja, null)
298
+ mgr.profiles.removePassword(id)
299
+ return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
300
+ }
188
301
  default: throw new Error('unknown profile operation: ' + req.op)
189
302
  }
190
303
  }
191
304
 
192
- async function atenderPeticiones () {
305
+ async function handleRequests () {
193
306
  try {
194
307
  const appr = readJsonSafe(approveReqFile)
195
308
  if (appr?.code) {
309
+ rm(approveReqFile)
310
+ // EL RESULTADO SE CONTESTA, no solo se anota en el log del servicio. Un código
311
+ // equivocado NO emite certificado (lo corta `enroll.js`), pero eso se quedaba en
312
+ // esta consola: quien aprobaba desde la TUI leía «Dispositivo aprobado», el
313
+ // pendiente desaparecía de la pantalla y el aparato seguía esperando al otro lado
314
+ // sin que nadie pudiera reintentar.
315
+ const answer = (extra) => writeJson(approveFile, { v: 1, at: Date.now(), req: appr.id || null, ...extra })
196
316
  try {
197
317
  const vault = targetOf(appr)
198
- const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile); console.log('[vault] aprobado %s', r.deviceId)
199
- } catch (e) { console.error('[vault] approval failed:', e.message) }
200
- rm(approveReqFile)
318
+ if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
319
+ const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
320
+ console.log('[vault] aprobado %s', r.deviceId)
321
+ answer({ ok: true, deviceId: r.deviceId || null })
322
+ } catch (e) {
323
+ console.error('[vault] approval failed:', e.message)
324
+ answer({ ok: false, error: e.message, code: e.code || 'APPROVE_FAILED' })
325
+ }
201
326
  }
202
327
  const rej = readJsonSafe(rejectReqFile)
203
328
  if (rej?.deviceId) {
@@ -232,53 +357,191 @@ export async function runDaemon () {
232
357
  } catch (e) { console.error('[vault] revocation failed:', e.message) }
233
358
  rm(revokeReqFile)
234
359
  }
235
- // Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
236
- // vive un instante en el mismo dir 0700 del vault y se borra al consumir.
360
+ // Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
361
+ // del CLI o de la TUI. El archivo con el valor vive un instante en el mismo dir
362
+ // 0700 del vault y se borra al consumir.
237
363
  const sec = readJsonSafe(secretReqFile)
238
364
  if (sec?.op) {
239
- rm(secretReqFile)
365
+ rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
366
+ lastSecretError = null
240
367
  try {
241
368
  const vault = targetOf(sec)
242
- if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
243
- else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
244
- } catch (e) { console.error('[vault] secret failed:', e.message) }
369
+ // Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
370
+ // variables entran de una vez y sale UN solo aviso de cambio, para que el
371
+ // servicio no se reinicie a media carga y arranque con la mitad puesta.
372
+ // La CONTRASEÑA, si vino, se convierte en la llave que abre la copia de
373
+ // RECUPERACIÓN, y no se guarda en ningún sitio: se usa y se suelta. Desde v5
374
+ // solo la piden las operaciones que LEEN —ver un valor, cambiar su visibilidad,
375
+ // convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
376
+ // a la llave de la máquina, que es la protección de antes de esto (y el vault lo
377
+ // avisa al arrancar).
378
+ const ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
379
+ if (sec.op === 'migrate') {
380
+ // Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
381
+ // escritura (servicios del cajón + aparatos que administran). Con una lista
382
+ // propia aquí, lo convertido quedaba sellado solo a los servicios y el dueño
383
+ // no podía ver desde su consola nada de lo que ya tenía.
384
+ const r = await vault.migrateSecrets(null, ak)
385
+ if (!r.migrated) console.log('[vault] nothing to migrate: %s', r.reason)
386
+ else {
387
+ console.log('[vault] secrets SEALED (v%d -> v5). Backup left at secrets.json.v%d.bak', r.from, r.from)
388
+ for (const [owner, sin] of Object.entries(r.sinLlave || {})) {
389
+ console.log('[vault] WARNING %s: %d member(s) without an encryption key will NOT read their variables', owner, sin.length)
390
+ }
391
+ }
392
+ } else if (sec.op === 'batch') {
393
+ const changed = sec.pub
394
+ ? await vault.applyDeviceSecrets(sec.pub, sec.items, { by: null })
395
+ : await vault.applySecrets(sec.ns, sec.items, { by: null })
396
+ console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
397
+ } 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) }
398
+ else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
399
+ 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) }
400
+ else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
401
+ // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
402
+ // rotar de verdad el cajón del que salió alguien. Las dos cosas abren, así que
403
+ // van con la frase — y por eso se hacen aquí y no al escribir.
404
+ else if (sec.op === 'settle') {
405
+ const r = await vault.settleSecretDebts(ak)
406
+ const n = Object.keys(r).length
407
+ console.log(n ? `[vault] ${n} pending drawer(s) settled` : '[vault] nothing pending')
408
+ }
409
+ // Ver el valor de una privada: lo único que la frase guarda (§8.3).
410
+ else if (sec.op === 'reveal') {
411
+ const value = await vault.revealSecret(sec.owner, sec.key, ak)
412
+ lastSecretValue = { owner: sec.owner, key: sec.key, value }
413
+ }
414
+ // Qué versiones anteriores hay (sin valores: son sobres).
415
+ else if (sec.op === 'history') {
416
+ lastSecretHistory = { owner: sec.owner || null, key: sec.key || null, items: vault.secretHistory(sec.owner || null, sec.key || null) }
417
+ }
418
+ // REVERTIR: abrir la versión vieja (frase) y volver a guardarla (nada).
419
+ else if (sec.op === 'revert') {
420
+ const ok = await vault.revertSecret(sec.owner, sec.key, sec.ts, { adminKey: ak })
421
+ if (!ok) throw new Error('that version is not in the history any more')
422
+ console.log('[vault] secret reverted: %s/%s', sec.owner, sec.key)
423
+ }
424
+ // Visibilidad: si el valor puede salir hacia la consola remota. No toca el valor.
425
+ else if (sec.op === 'vis') { await vault.setSecretVisibility(sec.ns, sec.key, sec.public, ak); console.log('[vault] secret visibility: %s/%s → %s', sec.ns, sec.key, sec.public ? 'public' : 'private') }
426
+ else if (sec.op === 'dev-vis') { await vault.setDeviceSecretVisibility(sec.pub, sec.key, sec.public, ak); console.log('[vault] device secret visibility: %s → %s', sec.key, sec.public ? 'public' : 'private') }
427
+ } catch (e) {
428
+ lastSecretError = {
429
+ error: e.message,
430
+ code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
431
+ }
432
+ console.error('[vault] secret failed:', e.message)
433
+ }
245
434
  }
246
435
  // Perfiles / candado.
247
436
  const preq = readJsonSafe(profileReqFile)
248
437
  if (preq?.op) {
249
438
  rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
250
- let extra = {}
251
- try { extra = await handleProfileRequest(preq) }
439
+ // `req`: el id de la petición viaja de vuelta en el volcado. Sin él, quien
440
+ // espera podía quedarse con un volcado anterior —el daemon los escribe también
441
+ // por su cuenta— y dar por contestado lo que aún no se había hecho.
442
+ let extra = { req: preq.id || null }
443
+ try { extra = { ...extra, ...(await handleProfileRequest(preq)) } }
252
444
  // `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
253
445
  // que leerse en el idioma de quien lo lee, no en el del daemon).
254
- catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] profile: %s', e.message) }
446
+ catch (e) {
447
+ // `waitSec`/`tries` viajan con el error: quien lo enseña necesita el dato, no
448
+ // solo el motivo («espera 32 s» y «van 9 intentos» son lo que cambia la conducta).
449
+ extra = { req: preq.id || null, error: e.message, ...(e.code ? { code: e.code } : {}), ...(e.waitSec ? { waitSec: e.waitSec } : {}), ...(e.tries ? { tries: e.tries } : {}) }
450
+ console.error('[vault] profile: %s', e.message)
451
+ }
255
452
  dumpProfiles(extra)
256
453
  } else {
257
- dumpProfiles()
454
+ // NADIE PREGUNTÓ: se refresca el estado, pero NO se pisa `profiles-list.json`.
455
+ // Ese archivo es la RESPUESTA a una petición, y el daemon repasa su carpeta cada
456
+ // dos segundos: al volcarlo también sin que nadie lo pidiera, un repaso que caía
457
+ // entre la respuesta y quien la esperaba se la llevaba por delante. Eso es lo que
458
+ // hacía que un `unlock` correcto contestara «sigue cerrada» y que un «contraseña
459
+ // incorrecta» se perdiera sin llegar a verse. Quien lee esta lista —la TUI y el
460
+ // CLI— siempre pide antes, así que no se queda sin ella.
461
+ writeState()
462
+ }
463
+ // Volcados que lee la CLI (`devices`, `members`, `secret list`) y la TUI. A QUÉ
464
+ // perfil miran lo dice dump-request.json; sin él, al activo.
465
+ //
466
+ // SOLO SI ALGUIEN LOS PIDIÓ. Estos archivos son la RESPUESTA a una petición, igual
467
+ // que `profiles-list.json` (ver arriba), y el daemon pasa por aquí cada dos segundos
468
+ // —y otra vez, en el acto, si algo llegó mientras atendía—. Volcarlos también sin que
469
+ // nadie preguntara se llevaba por delante la respuesta recién escrita: quien esperaba
470
+ // veía un volcado con `req: null` en vez del suyo, seguía esperando y a los seis
471
+ // segundos se rendía. Eso era la TUI colgada en «Cargando dispositivos…» y luego «el
472
+ // daemon no responde» — con el daemon sano y contestando en milisegundos.
473
+ // NO SE BORRA LO QUE NO SE PUDO LEER. `readJsonSafe` devuelve null tanto si el
474
+ // archivo no está como si llegó a medias (`fs.watch` avisa al crearlo, no al
475
+ // terminar de escribirlo), y borrarlo en ese segundo caso destruía la petición: el
476
+ // que la había pedido esperaba seis segundos y leía «el daemon no respondió», con el
477
+ // daemon sano y contestando lo demás. Si no parsea se deja donde está y lo recoge el
478
+ // repaso de 2 s, que es la misma regla que ya tenía la petición de emparejamiento.
479
+ const dumpReq = readJsonSafe(dumpReqFile)
480
+ if (dumpReq || !fs.existsSync(dumpReqFile)) rm(dumpReqFile)
481
+ const meReq = readJsonSafe(meReqFile)
482
+ if (!dumpReq && !meReq) return
483
+ const t = resolveTarget(dumpReq || meReq || appr || rej || req || sec || {}) || { id: mgr.currentId(), vault: mgr.current(), locked: false }
484
+ // El id de la petición vuelve en cada volcado, para que quien espera sepa que le
485
+ // contestan a ÉL y no lea el volcado de la vuelta anterior (ver `waitFor`).
486
+ const reqId = dumpReq?.id || null
487
+ if (t.locked) {
488
+ // BLOQUEADO: se contesta que lo está, y nada más. Los volcados se escriben igual
489
+ // (quien pregunta espera una respuesta, no un plantón) pero VACÍOS: ni aparatos, ni
490
+ // nombres de variables, ni acta. Antes el candado no tapaba ninguna de las tres.
491
+ const closed = { v: 1, at: Date.now(), req: reqId, profile: t.id, locked: true }
492
+ if (dumpReq) {
493
+ writeJson(secretsListFile, { ...closed, ns: {}, dev: [] })
494
+ writeJson(devFile, { ...closed, issued: [], revoked: [] })
495
+ writeJson(path.join(dir, 'acta.json'), { ...closed, members: [] })
496
+ }
497
+ rm(meReqFile)
498
+ if (meReq) writeJson(meFile, { ...closed, req: meReq.id || null, me: null })
499
+ return
500
+ }
501
+ if (dumpReq) {
502
+ // Los DOS cajones: `ns` (por scope, que comparten todos los aparatos del perfil) y
503
+ // `dev` (las propias de cada aparato). Con el VALOR de las públicas —que es lo que
504
+ // pública significa— y sin el de las privadas, que no salen del proceso.
505
+ //
506
+ // Ese valor queda escrito en claro en este archivo (0600, y quien lo lee lo borra
507
+ // en cuanto lo tiene). Es material que su dueño marcó como mostrable, y aquí ya
508
+ // viaja a un navegador; lo que NO puede pasar es que se quede en el disco esperando
509
+ // a que alguien copie la carpeta, porque eso sí burlaría el cifrado en reposo.
510
+ writeJson(secretsListFile, {
511
+ v: 2, at: Date.now(), req: reqId, profile: t.id,
512
+ ns: t.vault.listSecrets(),
513
+ dev: await t.vault.listDeviceSecrets(),
514
+ // Lo que quedó a deber un sellado. Va en el volcado porque si no se ve, no se
515
+ // salda: son cajones cuyos miembros NO están leyendo sus variables.
516
+ pending: await t.vault.secretDebts(),
517
+ // Y quién NO puede abrir lo suyo. Es lo mismo visto desde el aparato, que es
518
+ // como lo mira quien administra: «este servicio está en el acta y aun así no
519
+ // arranca». Sin esto solo se veía en el log del propio servicio.
520
+ incomplete: await t.vault.incompleteMembers(),
521
+ ...(lastSecretError ? { secretError: lastSecretError } : {}),
522
+ ...(lastSecretValue ? { revealed: lastSecretValue } : {}),
523
+ ...(lastSecretHistory ? { history: lastSecretHistory } : {})
524
+ })
525
+ lastSecretError = null
526
+ lastSecretValue = null
527
+ lastSecretHistory = null
528
+ writeJson(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
529
+ // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
530
+ try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
258
531
  }
259
- // Volcados que lee la CLI (`devices`, `secret list`). A QUÉ perfil miran lo
260
- // dice dump-request.json; sin él, al activo.
261
- const dumpReq = readJsonSafe(dumpReqFile); rm(dumpReqFile)
262
- const t = resolveTarget(dumpReq || appr || rej || req || sec || {}) || { id: mgr.currentId(), vault: mgr.current() }
263
- // Nombres de secretos, nunca valores.
264
- writeJson(secretsListFile, { v: 1, at: Date.now(), profile: t.id, ns: t.vault.listSecrets() })
265
- writeJson(devFile, { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.listDevices()) })
266
- // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
267
- try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
268
532
 
269
533
  // PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
270
534
  // Solo se vuelca cuando se PIDE, no en cada señal: es contenido del usuario y no tiene
271
535
  // por qué quedar escrito en un archivo suelto cada vez que alguien mira los miembros.
272
536
  // La FOTO no entra en el volcado (son hasta ~90 KB de data-URI que nadie va a leer en
273
537
  // una terminal): solo se dice que la hay, de qué tipo y cuánto pesa.
274
- const meReq = readJsonSafe(meReqFile)
275
538
  if (meReq) {
276
539
  rm(meReqFile)
277
540
  try {
278
541
  const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
279
542
  const { me } = tm.vault.threads.methods.profileGet()
280
- const { avatar, ...resto } = me || {}
281
- writeJson(meFile, { v: 1, at: Date.now(), profile: tm.id, me: me ? { ...resto, avatar: avatarInfo(avatar) } : null })
543
+ const { avatar, ...rest } = me || {}
544
+ writeJson(meFile, { v: 1, at: Date.now(), req: meReq.id || null, profile: tm.id, me: me ? { ...rest, avatar: avatarInfo(avatar) } : null })
282
545
  } catch (e) { console.error('[vault] could not dump the profile:', e.message) }
283
546
  }
284
547
  } catch (e) {
@@ -298,38 +561,44 @@ export async function runDaemon () {
298
561
  // sistemas, más un repaso periódico por si el watcher se pierde un evento (pasa en
299
562
  // carpetas de red y en algunos montajes). Las señales se mantienen donde existen: no
300
563
  // estorban y hacen que la respuesta sea inmediata.
301
- const REPASO_MS = 2000
302
- let atendiendo = false
303
- async function atender () {
304
- if (atendiendo) return // una a la vez: las peticiones se consumen y se borran
305
- atendiendo = true
564
+ const SWEEP_MS = 2000
565
+ let serving = false
566
+ let anotherRound = false
567
+ async function serve () {
568
+ // Una a la vez (las peticiones se consumen y se borran), pero lo que llegue mientras
569
+ // tanto NO se pierde: se anota y se da otra vuelta al terminar. Antes se descartaba,
570
+ // y la petición se quedaba esperando al repaso de 2 s — tiempo de sobra para que
571
+ // quien pidió escribiera la siguiente encima.
572
+ if (serving) { anotherRound = true; return }
573
+ serving = true
306
574
  try {
307
575
  // OJO: `fs.watch` avisa al CREAR el archivo, antes de que el CLI termine de
308
576
  // escribirlo. Si se lee a medias, el JSON no parsea y la petición se pierde con
309
577
  // sus datos — y el emparejamiento salía como si fuera de un dispositivo normal,
310
578
  // sin el `--service`, emitiendo un cert con el scope equivocado. Así que solo se
311
579
  // atiende cuando el archivo YA parsea; si no, lo recoge el repaso de 2 s.
312
- if (readJsonSafe(pairReqFile)) await atenderEmparejamiento()
313
- await atenderPeticiones()
580
+ if (readJsonSafe(pairReqFile)) await handlePairingRequest()
581
+ await handleRequests()
314
582
  } catch (e) { console.error('[vault] error serving a request:', e.message) }
315
- finally { atendiendo = false }
583
+ finally { serving = false }
584
+ if (anotherRound) { anotherRound = false; await serve() }
316
585
  }
317
586
 
318
587
  try {
319
- fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) atender() })
588
+ fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) serve() })
320
589
  } catch (e) {
321
590
  console.error('[vault] could not watch %s (%s); will be served by polling only', dir, e.message)
322
591
  }
323
- const repaso = setInterval(atender, REPASO_MS)
324
- repaso.unref?.()
592
+ const sweep = setInterval(serve, SWEEP_MS)
593
+ sweep.unref?.()
325
594
 
326
595
  // POSIX: la señal sigue valiendo como atajo inmediato. En Windows no existe y no pasa nada.
327
596
  // Van por `atender()` y NO llaman directo: si no, la señal y el vigilante corren a la
328
597
  // vez, la primera consume la petición y la segunda la lee vacía — y un `pair --service`
329
598
  // acababa emitiendo un cert de dispositivo normal, con el scope equivocado.
330
599
  if (process.platform !== 'win32') {
331
- process.on('SIGUSR1', () => { atender() })
332
- process.on('SIGUSR2', () => { atender() })
600
+ process.on('SIGUSR1', () => { serve() })
601
+ process.on('SIGUSR2', () => { serve() })
333
602
  }
334
603
 
335
604
  // --- apagado limpio ---
package/src/manager.js CHANGED
@@ -24,11 +24,11 @@ import { dataDir, ensureDir } from './paths.js'
24
24
  */
25
25
  export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
26
26
  if (!isMaster || memberCount <= 1) return true
27
- const otros = memberCount - 1
27
+ const others = memberCount - 1
28
28
  const e = new Error(
29
- `la cuenta "${name}" la manda esta bóveda y tiene ${otros} dispositivo(s) más: ` +
30
- 'pásale primero el mando a uno que esté conectado. Si la borras así, se quedan ' +
31
- 'con su llave y sin nadie que pueda volver a firmar el acta.'
29
+ `this vault is the master of account "${name}" and it has ${others} more device(s): ` +
30
+ 'hand the master over to one that is connected first. Deleting it like this leaves them ' +
31
+ 'with their key and nobody able to sign the record again.'
32
32
  )
33
33
  e.code = 'MASTER_WITH_MEMBERS'
34
34
  e.members = memberCount
@@ -56,6 +56,12 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
56
56
  proxyUrl,
57
57
  log: (...a) => log(`[${tag}]`, ...a),
58
58
  isLocked: () => profiles.isLocked(id),
59
+ // Para poder DECIR que este perfil no tiene contraseña, y por tanto que sus
60
+ // variables privadas se abren con material que vive en este mismo disco.
61
+ hasPassword: () => !!profiles.get(id)?.protected,
62
+ // Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
63
+ // convertirla en la llave que abre la copia maestra de los secretos.
64
+ deriveAdminKey: (password) => profiles.adminKey(id, password),
59
65
  // Camino A: nació para adoptar la cuenta de un aparato (ver profiles.add).
60
66
  forAdoption: !!p?.adopt,
61
67
  // Ya adoptó: la marca se consume (no vuelve a estar «a la espera»).
@@ -114,11 +120,11 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
114
120
  // se lleva su llave y su copia, y la cuenta sigue viva donde vive el master.)
115
121
  const v = running.get(id)
116
122
  if (v) {
117
- const [soyMaster, acta] = await Promise.all([
123
+ const [isMaster, record] = await Promise.all([
118
124
  v.isMaster().catch(() => false),
119
125
  v.profileMembers().catch(() => ({ members: [] }))
120
126
  ])
121
- assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
127
+ assertCanRemove({ isMaster, memberCount: (record?.members || []).length, name: profiles.get(id)?.name || id })
122
128
  } else {
123
129
  log('[vault] profile %s is not open: deleting without being able to check its record', id)
124
130
  }