@dotrino/vaultd 0.75.1 → 0.77.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.
@@ -421,7 +421,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
421
421
  * Renueva el cert automáticamente si está por vencer (best-effort).
422
422
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
423
423
  */
424
- export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
424
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
425
425
  let saved = null
426
426
  if (dir) saved = readServiceIdentity(dir)
427
427
  // Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
@@ -465,7 +465,15 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
465
465
  recordar({ actaSeq: acta.seq })
466
466
  }
467
467
 
468
- const renovar = typeof saved?.actaSeq === 'number' && typeof cert?.seq === 'number' && saved.actaSeq > cert.seq
468
+ // DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
469
+ // · el nuestro es del MODELO VIEJO (lleva `exp` y no `seq`). Ese hay que cambiarlo sí
470
+ // o sí: se acepta por el repliegue de migración, pero muere en su fecha y con él el
471
+ // servicio. Sin esta rama la migración no terminaba nunca — el disparador comparaba
472
+ // `seq` contra `seq`, y un papel viejo no tiene ninguno, así que no saltaba jamás.
473
+ // · o el acta que hemos visto va por delante del papel: el dueño cambió permisos.
474
+ const legado = !!cert && typeof cert.seq !== 'number'
475
+ const atrasado = typeof saved?.actaSeq === 'number' && typeof cert?.seq === 'number' && saved.actaSeq > cert.seq
476
+ const renovar = legado || atrasado
469
477
 
470
478
  // RENOVAR YA NO ES UNA TAREA DE CALENDARIO. El papel no vence: lo único que obliga a
471
479
  // pedir otro es que la bóveda diga que el nuestro se quedó atrás respecto del acta, y
@@ -481,6 +489,11 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
481
489
  if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
482
490
  cert = res.cert
483
491
  recordar({ cert })
492
+ // QUIEN NO PASA `dir` GUARDA ÉL. Un servicio que lleva su identidad en su propio
493
+ // archivo (`link.json`) y nos pasa el enlace a mano no tiene `saved`, así que
494
+ // `recordar` no escribe nada: sin esto renovaba en cada arranque y volvía a
495
+ // empezar, y su papel del modelo viejo no se cambiaba NUNCA.
496
+ try { onCert?.(cert) } catch (_) {}
484
497
  }
485
498
  } catch (_) { /* la renovación no bloquea el fetch */ }
486
499
  }
@@ -978,11 +991,11 @@ function fingerprintOf (secrets) {
978
991
  * servicio no opera hasta que esto resuelva — esa es la regla.
979
992
  * @returns {Promise<Record<string,string>>}
980
993
  */
981
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
994
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
982
995
  let delay = retryMs
983
996
  for (;;) {
984
997
  try {
985
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
998
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert })
986
999
  } catch (e) {
987
1000
  // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
988
1001
  // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.75.1",
3
+ "version": "0.77.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.74.0",
23
+ "@dotrino/identity": "0.74.1",
24
24
  "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -419,7 +419,14 @@ async function cmdJoin (rest) {
419
419
  process.exit(2)
420
420
  }
421
421
  const qr = parseInvite(texto)
422
- if (!qr?.sn || !qr?.iss || !qr?.proxy) {
422
+ // QUÉ HACE FALTA DE VERDAD: el nonce de la sesión (`sn`) y una forma de alcanzar a la
423
+ // otra bóveda — la CITA del proxio (`conn`) en la invitación corta, o su llave (`iss`)
424
+ // en la larga. Quien la consume es `enrollDevice`, que sabe canjear la cita.
425
+ //
426
+ // Esto exigía `iss` SIEMPRE, y la invitación corta no lo lleva (la llave dejó de viajar
427
+ // cuando el QR se acortó, `lib/src/invite.js`). O sea que `join` rechazaba todas las
428
+ // invitaciones que emite `pair` hoy: el multivault entero no se podía montar.
429
+ if (!qr?.sn || !(qr?.conn || qr?.iss)) {
423
430
  console.error('Esa invitación no se entiende. Pega la línea completa que imprime «dotrino-vault pair».')
424
431
  process.exit(2)
425
432
  }
package/src/daemon.js CHANGED
@@ -791,7 +791,10 @@ export async function runDaemon () {
791
791
 
792
792
  let qr = null
793
793
  try { qr = parseInvite(texto) } catch (_) {}
794
- if (!qr?.sn || !qr?.iss || !qr?.proxy) {
794
+ // Lo mismo que `dotrino-vault join` (ver `ctl.js`): basta `sn` + la cita del proxio o
795
+ // la llave. Exigir `iss` dejaba fuera a la invitación corta, que es la única que emite
796
+ // `pair` hoy — así que este camino de despliegue no había forma de que funcionara.
797
+ if (!qr?.sn || !(qr?.conn || qr?.iss)) {
795
798
  console.error('[vault] the invitation in DOTRINO_JOIN is not valid; ignoring it')
796
799
  return
797
800
  }
@@ -799,7 +802,7 @@ export async function runDaemon () {
799
802
  const hecho = readJsonSafe(marca)
800
803
  if (hecho?.sn === qr.sn) return // ya se usó: un reinicio no vuelve a pedir entrar
801
804
 
802
- writeJson(marca, { at: Date.now(), sn: qr.sn, iss: qr.iss })
805
+ writeJson(marca, { at: Date.now(), sn: qr.sn, ...(qr.iss ? { iss: qr.iss } : {}) })
803
806
  writeJson(path.join(dir, 'join-request.json'), {
804
807
  qr,
805
808
  label: 'bóveda',
package/src/vault.js CHANGED
@@ -688,7 +688,6 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
688
688
  // cert fresco (misma sub-clave y scope) sin QR ni aprobación — sigue siendo el
689
689
  // mismo dispositivo enrolado, solo extiende la ventana. Un cert vencido o
690
690
  // revocado NO puede renovarse (ahí sí toca re-emparejar con aprobación).
691
- const RENEW_TTL_MS = 30 * 24 * 60 * 60 * 1000
692
691
  async function handleRenew (from, p) {
693
692
  if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
694
693
  const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
@@ -714,9 +713,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
714
713
  audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
715
714
  return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
716
715
  }
717
- const { cert } = await identity.signDelegation(p.cert.sub, scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
718
- audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope })
719
- log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} (30 days)`)
716
+ // RENOVAR NO RETIRA EL PAPEL ANTERIOR (`supersede: false`), y esto costó dos servicios.
717
+ //
718
+ // Retirarlo al emitir parece limpio —un aparato, un papel— pero convierte cualquier
719
+ // renovación que falle DESPUÉS de emitirse en una expulsión permanente: la máquina se
720
+ // queda con el papel viejo, que acaba de quedar revocado, y ya no puede ni pedir otro.
721
+ // Pasó dos veces en la migración del VPS (el registro de selladores y el bot social):
722
+ // el papel nuevo salió, la respuesta no llegó a guardarse, y al reintentar la bóveda
723
+ // contestaba `unauthorized: revoked`. Re-emparejar a mano, con TTY, por una renovación.
724
+ //
725
+ // Que convivan dos papeles del mismo aparato ya no es el problema que era: ninguno
726
+ // vence, pero lo que pueden hacer lo decide el ACTA en cada mostrador, así que el
727
+ // viejo no autoriza nada que el nuevo no autorice. Y quitar el aparato de verdad sigue
728
+ // siendo `revokeDevice`, que los retira TODOS de una vez.
729
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '', supersede: false })
730
+ audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope, seq: cert.seq })
731
+ log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} · record #${cert.seq}`)
720
732
  // El acta viaja con el papel: sin ella quien lo recibe no puede comprobar que lo firmó
721
733
  // una selladora del perfil, que es lo que sustituyó a «lo firmó la maestra».
722
734
  reply(from, { type: MSG.RENEWED, cert, acta: record })