@dotrino/vaultd 0.127.0 → 0.128.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.
@@ -313,7 +313,7 @@ export async function sayHello (client, qr) {
313
313
  const hello = await new Promise((resolve, reject) => {
314
314
  const off = client.on('message', (_f, p) => {
315
315
  if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
316
- else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
316
+ else if (p?.type === MSG.ERROR) { finish(); reject(vaultError(p)) }
317
317
  })
318
318
  const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
319
319
  const finish = () => { off(); clearTimeout(t) }
@@ -387,7 +387,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
387
387
  if (p.type === MSG.ENROLL_CHALLENGE) {
388
388
  const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
389
389
  show({ deviceId, code })
390
- } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
390
+ } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(vaultError(p)) }
391
391
  })
392
392
  const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
393
393
  const cleanup = () => { off(); clearTimeout(t) }
@@ -507,7 +507,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
507
507
  const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
508
508
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
509
509
  const res = await pending
510
- if (res.type === MSG.ERROR) throw new Error(res.error)
510
+ if (res.type === MSG.ERROR) throw vaultError(res)
511
511
  return { encPub, created, ok: true }
512
512
  } finally { client.close() }
513
513
  }
@@ -656,7 +656,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
656
656
  try { res = await pending; break } catch (e) { ultimo = e }
657
657
  }
658
658
  if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
659
- if (res.type === MSG.ERROR) throw new Error(res.error)
659
+ if (res.type === MSG.ERROR) throw vaultError(res)
660
660
 
661
661
  // CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
662
662
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
@@ -668,7 +668,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
668
668
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
669
669
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
670
670
  .catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
671
- if (res.type === MSG.ERROR) throw new Error(res.error)
671
+ if (res.type === MSG.ERROR) throw vaultError(res)
672
672
  }
673
673
 
674
674
  // AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
@@ -1244,9 +1244,23 @@ function fingerprintOf (secrets) {
1244
1244
  */
1245
1245
  const FINAL_CODES = new Set([
1246
1246
  'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
1247
- 'plaintext-var' // el valor está en claro en la bóveda: hay que volver a escribirlo
1247
+ 'plaintext-var', // el valor está en claro en la bóveda: hay que volver a escribirlo
1248
+ // NADIE EN EL ACTA PUEDE APROBAR: reintentar no lo arregla, y callarlo es lo peor. Pasó el
1249
+ // 2026-09-24: la limpieza de aparatos muertos se llevó al único teléfono con `aprueba`, y
1250
+ // `dotrino-env` se quedó reintentando cada minuto sin decir nada mientras la bóveda
1251
+ // contestaba exactamente qué había que hacer.
1252
+ 'no-approver'
1248
1253
  ])
1249
1254
 
1255
+ /**
1256
+ * Un `vault.error` como Error CON SU `code`. Antes se construía con el texto a secas, y el
1257
+ * código que la bóveda manda para distinguir los casos se perdía justo aquí — así que
1258
+ * `isFinal` no podía reconocer ninguno por `code` y todo se reintentaba.
1259
+ */
1260
+ function vaultError (p) {
1261
+ return Object.assign(new Error(p?.error || 'vault error'), { code: p?.code || null })
1262
+ }
1263
+
1250
1264
  export function isFinal (e) {
1251
1265
  if (FINAL_CODES.has(e?.code)) return true
1252
1266
  return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.127.0",
3
+ "version": "0.128.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": {
package/src/approvals.js CHANGED
@@ -185,4 +185,20 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
185
185
  return api
186
186
  }
187
187
 
188
- export default { createApprovals, createGrants, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
188
+ /**
189
+ * ¿SE LLEVÓ UNA LIMPIEZA AL ÚLTIMO QUE APROBABA? Devuelve los aprobadores que se fueron si
190
+ * antes había alguno y ahora no queda ninguno; si no, una lista vacía.
191
+ *
192
+ * Una cuenta SIN aprobadores puede ser legítima (todo desatendido, se actualiza sola), así
193
+ * que el aviso no es «no hay aprobadores»: es «LOS HABÍA y acabas de perder el último».
194
+ *
195
+ * @param {string[]} before llaves con `approve` antes
196
+ * @param {string[]} after llaves con `approve` después
197
+ * @param {string[]} removed llaves que salieron del acta
198
+ */
199
+ export function lastApproverGone (before, after, removed) {
200
+ if (!before.length || after.length) return []
201
+ return removed.filter((pub) => before.includes(pub))
202
+ }
203
+
204
+ export default { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
package/src/vault.js CHANGED
@@ -37,7 +37,7 @@ import { loadManifest, brokenOf } from '@dotrino/roadmap'
37
37
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
38
38
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
39
39
  // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
40
- import { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
40
+ import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
41
41
  import { readProcContext, sanitizeContext, commandFingerprint, commandLine } from '../lib/src/proc.js'
42
42
  import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
43
43
  import { makeSealer } from './sealer.js'
@@ -3617,12 +3617,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3617
3617
  // Si falla no se arrastra —abrir no depende de esto—, pero se dice.
3618
3618
  if (r?.locked === false) {
3619
3619
  try {
3620
+ const antes = await refreshActa()
3621
+ const aprobaban = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
3620
3622
  const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
3621
3623
  for (const d of removed) {
3622
3624
  const id = await deviceIdOf(d.pub).catch(() => null)
3623
3625
  audit('pruned', { device: id, label: d.label || '', reason: 'expired', seq })
3624
3626
  log(`[vault] unlock: removed ${id || '????-????'} ${d.label ? `(${d.label}) ` : ''}from the record: its certificate expired and cannot be renewed · record #${seq}`)
3625
3627
  }
3628
+ // ¿SE LLEVÓ AL ÚLTIMO QUE APROBABA? Entonces se dice ALTO. Quitarlo es correcto —con
3629
+ // el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
3630
+ // pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
3631
+ // el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
3632
+ const despues = await refreshActa()
3633
+ const quedan = (despues?.members || []).filter((m) => Acta.memberCan(despues, m.pub, 'approve')).map((m) => m.pub)
3634
+ const idosPub = lastApproverGone(aprobaban, quedan, removed.map((d) => d.pub))
3635
+ const idos = removed.filter((d) => idosPub.includes(d.pub))
3636
+ if (idos.length) {
3637
+ const quienes = (await Promise.all(idos.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
3638
+ audit('no-approver', { removed: idos.length, seq })
3639
+ log(`[vault] WARNING: nobody in the record can approve any more — ${quienes} was the last. Requests from devices without \`unattended\` will be refused until you enrol a device and grant it \`dotrino-vault caps <ID> +aprueba\``)
3640
+ }
3626
3641
  } catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
3627
3642
  }
3628
3643
  return { locked: r?.locked !== false }