@dotrino/vaultd 0.26.2 → 0.38.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.
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import fs from 'node:fs'
22
22
  import path from 'node:path'
23
+ import { createHash } from 'node:crypto'
23
24
  import {
24
25
  makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig,
25
26
  makePairingCode, commitCode, pubkeyId
@@ -58,7 +59,7 @@ function installNodeGlobals () {
58
59
  })
59
60
  }
60
61
  if (typeof globalThis.WebSocket === 'undefined') {
61
- throw new Error('este entorno no tiene WebSocket global: usa Node 22')
62
+ throw new Error('this runtime has no global WebSocket: use Node >=22')
62
63
  }
63
64
  }
64
65
 
@@ -70,17 +71,17 @@ function installNodeGlobals () {
70
71
  * que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
71
72
  * aprende la bóveda donde tú lo tecleas.
72
73
  */
73
- async function verificarHola (p, sn) {
74
+ async function verifyHello (p, sn) {
74
75
  const b = p?.body
75
- if (!b?.iss || b.sn !== sn) throw new Error('la bóveda contestó a otro emparejamiento')
76
+ if (!b?.iss || b.sn !== sn) throw new Error('the vault answered a different pairing')
76
77
  if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
77
- throw new Error('la respuesta de la bóveda no está bien firmada')
78
+ throw new Error('the vault reply is not properly signed')
78
79
  }
79
80
  // El modo también viene aquí, y aquí viene FIRMADO por la bóveda. Se comprueba
80
81
  // de nuevo aunque ya se haya mirado el del QR: en la forma corta el QR es un
81
82
  // código que pasó por manos ajenas, y esta es la primera vez que la bóveda
82
83
  // dice de su puño y letra qué se propone hacer.
83
- rechazarAdopcion(b.m)
84
+ rejectAdoption(b.m)
84
85
  return b
85
86
  }
86
87
 
@@ -98,11 +99,11 @@ async function verificarHola (p, sn) {
98
99
  * invitación, en vez de dejar que el viaje termine en un «intent-mismatch» del
99
100
  * otro lado que no le explica nada a nadie.
100
101
  */
101
- function rechazarAdopcion (modo) {
102
- if (modo !== 'adopt') return
102
+ function rejectAdoption (mode) {
103
+ if (mode !== 'adopt') return
103
104
  throw new Error(
104
- 'esta invitación se abrió para ADOPTAR la cuenta del aparato, y un agente no transfiere su identidad: ' +
105
- 'la suya se la cede el vault. Abre el emparejamiento sin `--adopt` (`dotrino-vault pair --service <ns>`).'
105
+ 'this invitation was opened to ADOPT the device account, and an agent does not transfer its identity: ' +
106
+ 'the vault grants it one. Open the pairing without `--adopt` (`dotrino-vault pair --service <ns>`).'
106
107
  )
107
108
  }
108
109
 
@@ -113,14 +114,14 @@ function rechazarAdopcion (modo) {
113
114
  * siempre significa lo mismo para quien lo lee: el código ya se usó o venció, y
114
115
  * hay que pedir otro en la bóveda. Se dice así, no con el error crudo.
115
116
  */
116
- async function resolverCita (client, code) {
117
- if (!code) throw new Error('la invitación no trae código de emparejamiento')
117
+ async function resolveAppointment (client, code) {
118
+ if (!code) throw new Error('the invitation carries no pairing code')
118
119
  if (typeof client.redeemPairingCode !== 'function') {
119
- throw new Error('el proxio no soporta códigos de emparejamiento (actualizá @dotrino/proxy-client)')
120
+ throw new Error('this proxy does not support pairing codes (update @dotrino/proxy-client)')
120
121
  }
121
122
  const r = await client.redeemPairingCode(code)
122
123
  if (!r?.ok || !r.instance) {
123
- throw new Error(`ese código no sirve: ${r?.error || 'no válido'}. Pedí uno nuevo en la bóveda.`)
124
+ throw new Error(`that code is no good: ${r?.error || 'not valid'}. Ask the vault for a new one.`)
124
125
  }
125
126
  return r.instance
126
127
  }
@@ -135,7 +136,7 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
135
136
  // para siempre y waitForSecrets no reintentaría. Le ponemos un timeout propio.
136
137
  let timer
137
138
  const timeout = new Promise((_, reject) => {
138
- timer = setTimeout(() => reject(new Error('timeout conectando al proxy')), connectTimeoutMs)
139
+ timer = setTimeout(() => reject(new Error('timeout connecting to the proxy')), connectTimeoutMs)
139
140
  })
140
141
  try {
141
142
  await Promise.race([client.connect(), timeout])
@@ -143,8 +144,8 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
143
144
  try { client.close() } catch (_) {}
144
145
  // El 'error' de transporte del cliente puede llegar como un Event sin
145
146
  // `message` → sin esto el operador ve una línea de error vacía.
146
- const why = e?.message || e?.type || 'error de transporte'
147
- throw new Error(`no se pudo conectar al proxy ${proxyUrl}: ${why}`)
147
+ const why = e?.message || e?.type || 'transport error'
148
+ throw new Error(`could not connect to the proxy ${proxyUrl}: ${why}`)
148
149
  } finally {
149
150
  clearTimeout(timer)
150
151
  }
@@ -163,7 +164,7 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
163
164
  const off = client.on('message', (_from, payload) => {
164
165
  if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
165
166
  })
166
- const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando respuesta del vault')) }, timeoutMs)
167
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for the vault reply')) }, timeoutMs)
167
168
  const cleanup = () => { off(); clearTimeout(t) }
168
169
  })
169
170
  }
@@ -231,26 +232,26 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
231
232
  // incluida la vieja, así que esto entiende cualquier invitación.
232
233
  if (typeof qr === 'string') {
233
234
  const o = parseInvite(qr)
234
- if (!o) throw new Error('eso no parece una invitación del vault (pega la salida de `dotrino-vault pair --service <ns>`)')
235
+ if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair --service <ns>`)')
235
236
  qr = o
236
237
  }
237
- if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
238
- rechazarAdopcion(qr.m)
239
- if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
240
- if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
241
- label = label || 'servicio:' + ns
238
+ if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
239
+ rejectAdoption(qr.m)
240
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
241
+ if (!dir) throw new Error('dir required (where to persist the service identity)')
242
+ label = label || 'service:' + ns
242
243
 
243
244
  // La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
244
245
  // el proxy, por ejemplo, esta llave es además su identidad de red, así que
245
246
  // reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
246
247
  // que se re-pineen a mano.
247
- const anterior = readServiceIdentity(dir)
248
+ const previous = readServiceIdentity(dir)
248
249
  let replaced = null
249
- if (anterior?.device?.publickey) {
250
+ if (previous?.device?.publickey) {
250
251
  replaced = {
251
- ns: anterior.ns,
252
- enrolledAt: anterior.enrolledAt,
253
- deviceId: (await pubkeyId(anterior.device.publickey)).slice(0, 8).toUpperCase()
252
+ ns: previous.ns,
253
+ enrolledAt: previous.enrolledAt,
254
+ deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
254
255
  }
255
256
  try { onReplace?.(replaced) } catch (_) {}
256
257
  }
@@ -262,17 +263,17 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
262
263
  // canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
263
264
  // que la emitió —lo dice el prefijo del propio código—, así que funciona
264
265
  // aunque la bóveda esté en otro proxio de la malla.
265
- const destino = await resolverCita(client, qr.conn)
266
- const hola = await new Promise((resolve, reject) => {
266
+ const target = await resolveAppointment(client, qr.conn)
267
+ const hello = await new Promise((resolve, reject) => {
267
268
  const off = client.on('message', (_f, p) => {
268
- if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then(resolve, reject) }
269
- else if (p?.type === MSG.ERROR) { fin(); reject(new Error(p.error)) }
269
+ if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
270
+ else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
270
271
  })
271
- const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
272
- const fin = () => { off(); clearTimeout(t) }
273
- try { client.send(destino, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
272
+ const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
273
+ const finish = () => { off(); clearTimeout(t) }
274
+ try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
274
275
  })
275
- qr = { ...qr, iss: hola.iss, proxy: hola.proxy || qr.proxy }
276
+ qr = { ...qr, iss: hello.iss, proxy: hello.proxy || qr.proxy }
276
277
  }
277
278
  try {
278
279
  const device = await makeDeviceKey({ label })
@@ -294,11 +295,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
294
295
  const off = client.on('message', (_from, p) => {
295
296
  if (!p || typeof p !== 'object') return
296
297
  if (p.type === MSG.ENROLL_CHALLENGE) {
297
- const show = onCode || (({ deviceId, code }) => console.log(`[vault-service] dispositivo ${deviceId} · aprueba en el vault: dotrino-vault approve ${code}`))
298
+ const show = onCode || (({ deviceId, code }) => console.log(`[vault-service] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
298
299
  show({ deviceId, code })
299
300
  } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
300
301
  })
301
- const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
302
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
302
303
  const cleanup = () => { off(); clearTimeout(t) }
303
304
  })
304
305
  client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
@@ -306,10 +307,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
306
307
 
307
308
  // Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
308
309
  // para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
309
- if (res.code !== code) throw new Error('el vault devolvió un código distinto al mostrado (posible relay malicioso)')
310
+ if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
310
311
  const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
311
- if (!v.ok) throw new Error('cert inválido: ' + v.reason)
312
- if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
312
+ if (!v.ok) throw new Error('invalid cert: ' + v.reason)
313
+ if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
313
314
 
314
315
  // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
315
316
  // anterior deja de existir en este agente.
@@ -332,9 +333,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
332
333
  masterPubkey = masterPubkey || saved?.iss
333
334
  device = device || saved?.device
334
335
  cert = cert || saved?.cert
335
- if (!isValidSecretsNs(ns)) throw new Error('ns inválido')
336
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
336
337
  if (!proxyUrl || !masterPubkey || !device || !cert) {
337
- throw new Error('servicio sin enrolar: corre primero enrollService() (falta service-identity.json)')
338
+ throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
338
339
  }
339
340
 
340
341
  const client = await freshClient(proxyUrl)
@@ -366,13 +367,13 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
366
367
 
367
368
  // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
368
369
  const body = res.body
369
- if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('respuesta de secretos malformada')
370
- if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('respuesta de secretos vencida')
370
+ if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
371
+ if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
371
372
  const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
372
- if (!ok) throw new Error('firma de la maestra inválida en la respuesta de secretos')
373
+ if (!ok) throw new Error('invalid master signature on the secrets reply')
373
374
 
374
375
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
375
- if (!payload || typeof payload.secrets !== 'object') throw new Error('sobre de secretos malformado')
376
+ if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
376
377
  return payload.secrets
377
378
  } finally { client.close() }
378
379
  }
@@ -388,6 +389,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
388
389
  * Lo que NO hace: recargar nada. El aviso no trae valores, y la reacción correcta
389
390
  * es que el proceso termine y lo levante su supervisor (ver `watchEnv`).
390
391
  *
392
+ * NO SE CONFÍA SOLO EN EL AVISO: al (re)conectar, COMPARA. Un aviso es un mensaje y
393
+ * los mensajes se pierden — el agente pudo estar vivo pero incomunicado, y entonces
394
+ * el aviso se encola en el proxio (24 h), llega tarde y lo tira la ventana de
395
+ * frescura (5 min), o caduca en la cola y no llega nunca. En los tres casos el
396
+ * agente se quedaba con la configuración vieja PARA SIEMPRE, porque al reconectar
397
+ * solo volvía a escuchar: nunca preguntaba. Ahora, cada vez que la conexión se
398
+ * restablece, pide el bundle y compara su huella con la que tiene aplicada; si no
399
+ * coincide, reacciona igual que si hubiera llegado el aviso. El aviso es el camino
400
+ * rápido; esto es el que no se pierde.
401
+ *
402
+ * Y lo mismo salva al interruptor de emergencia: si el cert se revocó mientras
403
+ * estaba incomunicado, el `REVOKED` se perdió igual, pero la comparación recibe
404
+ * «unauthorized: revoked» y apaga al agente ahí mismo.
405
+ *
391
406
  * Defensas, porque una señal que provoca reinicios es un arma si se descuida:
392
407
  * · **Firma de la maestra pineada** y `ns` que coincida. Sin esto, cualquiera
393
408
  * reinicia la flota ajena cuando quiera.
@@ -402,30 +417,53 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
402
417
  * @param {Object} opts
403
418
  * @param {string} opts.dir Identidad del servicio (`service-identity.json`).
404
419
  * @param {string} [opts.ns]
405
- * @param {(info:{ns:string, ts:number})=>void} opts.onChange
420
+ * @param {(info:{ns:string, ts:number, via:'notice'|'reconcile'})=>void} opts.onChange
421
+ * `via` dice por dónde se enteró: `notice` (llegó el aviso) o `reconcile` (nadie
422
+ * avisó y la comparación al reconectar encontró otra configuración).
406
423
  * @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
424
+ * @param {Record<string,string>} [opts.applied] El bundle que el agente tiene EN USO.
425
+ * Pasarlo es lo que permite detectar un cambio ya en la PRIMERA conexión — el que
426
+ * ocurrió entre que el agente pidió su configuración y logró ponerse a escuchar. Sin
427
+ * él no se pierde la protección, solo empieza una conexión más tarde: la primera se
428
+ * limita a tomar la referencia.
407
429
  * @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
430
+ * La comparación también lo respeta, pero **aplazándose** (el aviso sí se descarta):
431
+ * es lo que impide que un fallo sistemático se convierta en un ciclo de reinicios,
432
+ * porque acota los reinicios por comparación a uno por ventana.
408
433
  * @param {number} [opts.minIntervalMs=60000] Mínimo entre dos avisos obedecidos.
409
434
  * @param {number} [opts.jitterMs=5000] Espera aleatoria antes de avisar.
435
+ * @param {number} [opts.reconcileMinMs=30000] Mínimo entre dos comparaciones. Sin él,
436
+ * una conexión que va y viene cada cinco segundos le pediría el bundle a la bóveda
437
+ * cada cinco segundos, y son N agentes.
410
438
  * @param {(m:string)=>void} [opts.log]
411
- * @returns {Promise<{stop:()=>void}>}
439
+ * @returns {Promise<{stop:()=>void, reconcile:()=>Promise<boolean>}>}
440
+ * `reconcile()` fuerza la comparación (útil desde un chequeo de salud); devuelve si
441
+ * encontró un cambio.
412
442
  */
413
443
  export async function watchSecretsChanges ({
414
- dir, ns, onChange, onRevoked, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000, log = () => {}
444
+ dir, ns, onChange, onRevoked, applied, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
445
+ reconcileMinMs = 30000, log = () => {}
415
446
  } = {}) {
416
447
  const saved = dir ? readServiceIdentity(dir) : null
417
448
  ns = ns || saved?.ns
418
449
  if (!saved?.device || !saved?.cert || !saved?.iss || !saved?.proxy) {
419
- throw new Error('servicio sin enrolar: no hay a quién escuchar')
450
+ throw new Error('service not enrolled: nobody to listen to')
420
451
  }
421
452
  const master = saved.iss
422
- const nacido = Date.now()
423
- let ultimoTs = 0
424
- let ultimoObedecido = 0
425
- const enVuelo = new Set() // avisos cuya firma se está comprobando ahora mismo
426
- let parado = false
453
+ const bornAt = Date.now()
454
+ let lastTs = 0
455
+ let lastObeyed = 0
456
+ const inFlight = new Set() // avisos cuya firma se está comprobando ahora mismo
457
+ let stopped = false
427
458
  let client = null
428
- let reintento = null
459
+ let retryTimer = null
460
+ // Huella de la configuración EN USO. Comparar huellas y no valores es lo que permite
461
+ // decir «esto no es lo que está corriendo» sin volver a manejar los secretos.
462
+ let fingerprint = applied === undefined ? null : fingerprintOf(applied)
463
+ let firstConnection = true
464
+ let lastReconcile = 0
465
+ let reconciling = false
466
+ let reconcileRetry = null
429
467
 
430
468
  /**
431
469
  * REVOCACIÓN = interruptor de emergencia. Hasta ahora revocar un cert no le
@@ -433,12 +471,12 @@ export async function watchSecretsChanges ({
433
471
  * memoria hasta que alguien se acordara de reiniciarlo (el README decía lo
434
472
  * contrario). Teniendo la conexión abierta, el aviso llega y el agente se apaga
435
473
  * en el acto — y no vuelve, porque al arrancar `fetchSecrets` recibe
436
- * «no autorizado: revoked», que no se arregla reintentando.
474
+ * «unauthorized: revoked», que no se arregla reintentando.
437
475
  *
438
476
  * Sin gracia, sin piso y sin jitter, al revés que un cambio de configuración:
439
477
  * apagar algo comprometido es justo lo que no debe esperar su turno.
440
478
  */
441
- const atenderRevocacion = async (payload) => {
479
+ const handleRevocation = async (payload) => {
442
480
  const body = payload.body
443
481
  if (!body || body.op !== 'revoke') return
444
482
  // Que sea MI revocación y no la de otro dispositivo del mismo dueño.
@@ -451,69 +489,157 @@ export async function watchSecretsChanges ({
451
489
  try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
452
490
  }
453
491
 
454
- const atender = async (payload) => {
455
- if (payload?.type === MSG.REVOKED) return atenderRevocacion(payload)
492
+ const handleMessage = async (payload) => {
493
+ if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
456
494
  if (payload?.type !== MSG.SECRETS_CHANGED) return
457
495
  const body = payload.body
458
496
  if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
459
497
  if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) {
460
- return log('[vault] aviso de cambio con fecha fuera de ventana: ignorado')
498
+ return log('[vault] change notice dated outside the window: ignored')
461
499
  }
462
- if (body.ts <= ultimoTs) return log('[vault] aviso de cambio repetido: ignorado')
500
+ if (body.ts <= lastTs) return log('[vault] repeated change notice: ignored')
463
501
  // Dos copias del MISMO aviso pueden llegar a la vez, y comprobar la firma es
464
- // asíncrono: sin esta marca las dos pasarían el corte de `ultimoTs` antes de
502
+ // asíncrono: sin esta marca las dos pasarían el corte de `lastTs` antes de
465
503
  // que ninguna lo actualizara, y el agente se reiniciaría por partida doble.
466
- // La marca se pone antes del `await` y el `ultimoTs` DESPUÉS de verificar, para
504
+ // La marca se pone antes del `await` y el `lastTs` DESPUÉS de verificar, para
467
505
  // que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
468
- if (enVuelo.has(body.ts)) return
469
- enVuelo.add(body.ts)
470
- let valida = false
506
+ if (inFlight.has(body.ts)) return
507
+ inFlight.add(body.ts)
508
+ let valid = false
471
509
  try {
472
- valida = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
473
- } finally { enVuelo.delete(body.ts) }
474
- if (!valida) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
475
- if (body.ts <= ultimoTs) return
476
- ultimoTs = body.ts
477
-
478
- const ahora = Date.now()
479
- if (ahora - nacido < graceMs) {
510
+ valid = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
511
+ } finally { inFlight.delete(body.ts) }
512
+ if (!valid) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
513
+ if (body.ts <= lastTs) return
514
+ lastTs = body.ts
515
+
516
+ const now = Date.now()
517
+ if (now - bornAt < graceMs) {
480
518
  return log('[vault] change notice right after start: ignored (avoids the restart loop)')
481
519
  }
482
- if (ahora - ultimoObedecido < minIntervalMs) {
483
- return log('[vault] aviso de cambio demasiado seguido del anterior: ignorado')
520
+ if (now - lastObeyed < minIntervalMs) {
521
+ return log('[vault] change notice too close to the previous one: ignored')
484
522
  }
485
- ultimoObedecido = ahora
523
+ lastObeyed = now
486
524
 
487
- const espera = Math.floor(Math.random() * jitterMs)
488
- log(`[vault] the vault reports config for "${ns}" changed (in ${espera} ms)`)
489
- setTimeout(() => { if (!parado) { try { onChange?.({ ns, ts: body.ts }) } catch (e) { log('[vault] ' + e.message) } } }, espera)
525
+ const wait = Math.floor(Math.random() * jitterMs)
526
+ log(`[vault] the vault reports config for "${ns}" changed (in ${wait} ms)`)
527
+ setTimeout(() => { if (!stopped) { try { onChange?.({ ns, ts: body.ts, via: 'notice' }) } catch (e) { log('[vault] ' + e.message) } } }, wait)
490
528
  }
491
529
 
492
- const conectar = async () => {
493
- if (parado) return
530
+ /**
531
+ * PREGUNTA en vez de esperar a que le cuenten: pide el bundle y lo compara con el
532
+ * que está en uso. Es la red que recoge todo lo que el aviso deja caer — el que se
533
+ * perdió mientras el agente estaba incomunicado, el que llegó fuera de la ventana de
534
+ * frescura, el que caducó en la cola del proxio y el que el propio agente descartó.
535
+ *
536
+ * Lo que compara son DOS BUNDLES DE LA BÓVEDA, nunca el `.env` contra el bundle. Por
537
+ * eso recibir la configuración por primera vez —tarde, que es como la recibe el
538
+ * proxio— no es un cambio: la referencia es lo que el agente recibió, no lo que tenía
539
+ * antes de recibir nada. Es la razón de fondo por la que esto no puede volverse un
540
+ * ciclo de reinicios; el tope de frecuencia de abajo es el cinturón.
541
+ *
542
+ * No pasa por el piso entre avisos, y es a propósito: ese freno existe porque un aviso
543
+ * es una señal que alguien podría repetir para provocar reinicios. Esto no es una
544
+ * señal, es el estado real firmado por la maestra — si de verdad difiere, reiniciar es
545
+ * siempre lo correcto, y al volver ya coincide.
546
+ *
547
+ * @returns {Promise<boolean>} si encontró (y anunció) un cambio.
548
+ */
549
+ const reconcile = async (trigger) => {
550
+ if (stopped || reconciling) return false
551
+ if (Date.now() - lastReconcile < reconcileMinMs) return false
552
+ // TOPE DE FRECUENCIA, que es lo único que separa esto de un ciclo de reinicios.
553
+ // Reiniciar por comparación no puede repetirse más de una vez por gracia de
554
+ // arranque: si algo hiciera que la comparación fallara SIEMPRE, el proceso saldría
555
+ // cada 30 s y no cada dos, que es la diferencia entre que el supervisor lo note y
556
+ // que la máquina se pase el día arrancando.
557
+ //
558
+ // Y a diferencia del aviso, aquí no se DESCARTA: se APLAZA. Descartarlo era el
559
+ // defecto que este cambio vino a cerrar, así que reintroducirlo por la puerta de
560
+ // atrás sería el peor final posible.
561
+ const sinceStart = Date.now() - bornAt
562
+ if (sinceStart < graceMs) {
563
+ clearTimeout(reconcileRetry)
564
+ reconcileRetry = setTimeout(() => { reconcile(trigger).catch(() => {}) }, graceMs - sinceStart + 50)
565
+ reconcileRetry.unref?.()
566
+ return false
567
+ }
568
+ reconciling = true
569
+ let bundle = null
570
+ try {
571
+ bundle = await fetchSecrets({ dir, ns })
572
+ } catch (e) {
573
+ // El cert revocado mientras estaba incomunicado: el `REVOKED` firmado se perdió
574
+ // igual que el aviso, y esta es la única otra forma de enterarse. Lo demás (la
575
+ // bóveda apagada, el proxio a medio levantar) es transitorio y se reintenta en la
576
+ // siguiente conexión: no se apaga nada por no haber podido preguntar.
577
+ if (/unauthorized: revoked/.test(e.message)) {
578
+ log('[vault] ⚠ the vault REVOKED this agent cert (noticed on ' + trigger + '): shutting down')
579
+ try { onRevoked?.({ nonce: saved.cert?.nonce || null }) } catch (err) { log('[vault] ' + err.message) }
580
+ } else {
581
+ log('[vault] could not check the config on ' + trigger + ': ' + e.message)
582
+ }
583
+ return false
584
+ } finally {
585
+ reconciling = false
586
+ lastReconcile = Date.now()
587
+ }
588
+ const current = fingerprintOf(bundle)
589
+ if (fingerprint === null) { fingerprint = current; return false } // primera vez: solo tomar referencia
590
+ if (current === fingerprint) return false
591
+ fingerprint = current
592
+ lastObeyed = Date.now()
593
+ log(`[vault] the config for "${ns}" is not the one running (noticed on ${trigger}): the notice never arrived`)
594
+ if (!stopped) { try { onChange?.({ ns, ts: Date.now(), via: 'reconcile' }) } catch (e) { log('[vault] ' + e.message) } }
595
+ return true
596
+ }
597
+
598
+ const connect = async () => {
599
+ if (stopped) return
494
600
  try {
495
601
  client = await freshClient(saved.proxy)
496
602
  await identifyAsService(client, saved.device)
497
- client.on('message', (_from, p) => { atender(p).catch(() => {}) })
603
+ client.on('message', (_from, p) => { handleMessage(p).catch(() => {}) })
498
604
  // Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
499
605
  // eso es exactamente el momento en que uno querría enterarse de una rotación.
500
- client.on('disconnected', () => { if (!parado) reintento = setTimeout(conectar, 5000) })
606
+ client.on('disconnected', () => { if (!stopped) retryTimer = setTimeout(connect, 5000) })
501
607
  log('[vault] listening for config changes')
608
+ // Y COMPARAR, porque el rato sin conexión es justo cuando se pierde un aviso.
609
+ // También en la PRIMERA conexión, aunque el agente venga de pedir el bundle hace
610
+ // un instante: entre aquello y esto pudo pasar cualquier cosa —si el proxio estaba
611
+ // caído, esta primera conexión llega minutos después— y ahí ya no hay quien avise.
612
+ // Cuesta una consulta por arranque; el hueco que tapa no tiene límite.
613
+ const trigger = firstConnection ? 'startup' : 'reconnect'
614
+ firstConnection = false
615
+ reconcile(trigger).catch(() => {})
502
616
  } catch (e) {
503
- if (!parado) reintento = setTimeout(conectar, 5000)
617
+ if (!stopped) retryTimer = setTimeout(connect, 5000)
504
618
  }
505
619
  }
506
- await conectar()
620
+ await connect()
507
621
 
508
622
  return {
509
623
  stop () {
510
- parado = true
511
- clearTimeout(reintento)
624
+ stopped = true
625
+ clearTimeout(retryTimer)
626
+ clearTimeout(reconcileRetry)
512
627
  try { client?.close() } catch (_) {}
513
- }
628
+ },
629
+ reconcile: () => reconcile('demand')
514
630
  }
515
631
  }
516
632
 
633
+ /**
634
+ * Huella de un bundle. Las claves van ORDENADAS: el bundle se arma mezclando el cajón
635
+ * del scope con el del aparato, así que el mismo contenido puede llegar en otro orden y
636
+ * un cambio de orden no es un cambio de configuración.
637
+ */
638
+ function fingerprintOf (secrets) {
639
+ const pairs = Object.entries(secrets || {}).map(([k, v]) => [k, String(v)]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
640
+ return createHash('sha256').update(JSON.stringify(pairs)).digest('hex')
641
+ }
642
+
517
643
  /**
518
644
  * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
519
645
  * disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
@@ -528,7 +654,7 @@ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device,
528
654
  } catch (e) {
529
655
  // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
530
656
  // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
531
- if (/sin enrolar|ns inválido|no autorizado: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
657
+ if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
532
658
  try { onRetry?.(e, delay) } catch (_) {}
533
659
  await new Promise((r) => setTimeout(r, delay))
534
660
  delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.26.2",
3
+ "version": "0.38.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": {
@@ -13,7 +13,8 @@
13
13
  "start": "node bin/dotrino-vaultd.js",
14
14
  "pair": "node bin/dotrino-vaultd.js --pair",
15
15
  "tui": "node bin/dotrino-vault-tui.js",
16
- "test": "node --test test/*.test.mjs"
16
+ "test": "node --test test/*.test.mjs",
17
+ "type-check": "tsc --noEmit"
17
18
  },
18
19
  "engines": {
19
20
  "node": ">=20"
@@ -37,9 +38,13 @@
37
38
  "src",
38
39
  "README.md",
39
40
  "vendor",
40
- "lib/src"
41
+ "lib/src",
42
+ "!src/types.d.ts",
43
+ "!lib/src/types.d.ts"
41
44
  ],
42
45
  "devDependencies": {
43
- "@dotrino/remote-agent": "^0.3.0"
46
+ "@dotrino/remote-agent": "^0.3.0",
47
+ "typescript": "^5.7.3",
48
+ "@types/node": "^22.0.0"
44
49
  }
45
50
  }