@dotrino/vaultd 0.80.1 → 0.81.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.
@@ -666,7 +666,17 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
666
666
  const gen = e.gen ?? e.e?.gen ?? 0
667
667
  await comprobarFirma(e.owner, key, gen, e.e, e.seal)
668
668
  const cek = await cekDe(cual, gen)
669
- if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
669
+ if (!cek) {
670
+ // NO ES UN TROPIEZO: ES UN ESTADO QUE HAY QUE ARREGLAR EN LA BÓVEDA.
671
+ //
672
+ // Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
673
+ // NO PUEDE tenerla: envolver exige abrir la llave del cajón, y eso solo pasa cuando
674
+ // el dueño abre la bóveda. Reintentar no la crea. Lleva `code` porque el mensaje lo
675
+ // empareja otra capa y una frase no es un contrato (ver `dotrino-error-strings`).
676
+ throw Object.assign(
677
+ new Error(`no key to open ${key}: this device has no wrapping in its drawer — open the vault (dotrino-vault unlock) so it hands it the key`),
678
+ { code: 'no-wrapping' })
679
+ }
670
680
  out[key] = await decryptWithCek({ cek, envelope: e.e })
671
681
  }
672
682
  return out
@@ -991,16 +1001,34 @@ function fingerprintOf (secrets) {
991
1001
  * servicio no opera hasta que esto resuelva — esa es la regla.
992
1002
  * @returns {Promise<Record<string,string>>}
993
1003
  */
1004
+ /**
1005
+ * ¿ESTE FALLO SE ARREGLA SOLO ESPERANDO? Si no, reintentar es peor que rendirse.
1006
+ *
1007
+ * Lo NO transitorio exige que alguien haga algo: falta de enrolamiento, cert revocado o
1008
+ * vencido, scope equivocado → re-emparejar. Que te lo DENIEGUEN tampoco se reintenta: fue
1009
+ * una decisión, no un tropiezo.
1010
+ *
1011
+ * Y no tener ENVOLTURA en el cajón (`no-wrapping`) es el que costó una tarde. La bóveda
1012
+ * pedía aprobación, el teléfono timbraba, el dueño aprobaba, la bóveda contestaba un bundle
1013
+ * que este aparato no podía abrir, y el agente volvía a pedir — así que el teléfono
1014
+ * timbraba otra vez. «Sigo aprobando y aprobando», textual. Ninguna aprobación iba a crear
1015
+ * esa envoltura: envolver exige abrir la llave del cajón, o sea ABRIR la bóveda.
1016
+ *
1017
+ * Se decide por `e.code` donde lo hay: emparejar un mensaje por su texto se rompe en
1018
+ * silencio en cuanto alguien lo reescribe (ver `dotrino-error-strings`).
1019
+ */
1020
+ export function isFinal (e) {
1021
+ if (e?.code === 'no-wrapping') return true
1022
+ return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
1023
+ }
1024
+
994
1025
  export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
995
1026
  let delay = retryMs
996
1027
  for (;;) {
997
1028
  try {
998
1029
  return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert })
999
1030
  } catch (e) {
1000
- // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
1001
- // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
1002
- // Que te lo DENIEGUEN tampoco se reintenta: fue una decisión, no un tropiezo.
1003
- if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e.message)) throw e
1031
+ if (isFinal(e)) throw e
1004
1032
  try { onRetry?.(e, delay) } catch (_) {}
1005
1033
  await new Promise((r) => setTimeout(r, delay))
1006
1034
  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.80.1",
3
+ "version": "0.81.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/daemon.js CHANGED
@@ -332,7 +332,18 @@ export async function runDaemon () {
332
332
  r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '')
333
333
  if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
334
334
  else if (r?.wrapped) note = ' · llavero al día'
335
- } catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
335
+ // ABIERTA PERO SIN LLAVERO NO ES «ABIERTA». Si un cajón no se pudo reenvolver, sus
336
+ // aparatos no van a poder leer nada y hay que decirlo AQUÍ: contestar
337
+ // «desbloqueado» a secas dejaba la bóveda pareciendo sana, y el fallo aparecía
338
+ // días después como un servicio pidiendo sus claves en bucle.
339
+ if (r?.failed?.length) {
340
+ for (const f of r.failed) console.error('[vault] %s: could not reseal (%s)', f.owner, f.error)
341
+ note += ` · ⚠ ${r.failed.length} cajón(es) NO se pudieron reenvolver (${r.failed.map((f) => f.owner).join(', ')}): sus aparatos no podrán leerlos`
342
+ }
343
+ } catch (e) {
344
+ console.error('[vault] could not rebuild the keyring on unlock:', e.message)
345
+ note += ` · ⚠ el llavero NO se pudo rehacer (${e.message}): los aparatos no podrán leer sus cajones`
346
+ }
336
347
  finally { wipe(ak) }
337
348
  return { done: 'perfil desbloqueado' + note, autoLockMs: mgr.profiles.autoLockMs }
338
349
  }
package/src/tui/app.js CHANGED
@@ -1072,7 +1072,15 @@ async function onKeyCaps (term, st, key) {
1072
1072
  else salir()
1073
1073
  return true
1074
1074
  }
1075
- if (key.name === 'f5') { st.capsDraft = null; await refreshMembers(term, st); return true }
1075
+ if (key.name === 'f5') {
1076
+ // F5 PREGUNTA IGUAL QUE ESC. Refrescar trae el acta de nuevo y el borrador deja de
1077
+ // tener con qué compararse, así que hay que tirarlo — pero tirarlo callando es perder
1078
+ // trabajo sin decirlo, que es lo mismo que Esc ya no hace.
1079
+ const refrescar = async () => { st.capsDraft = null; await refreshMembers(term, st) }
1080
+ if (sucio) setConfirm(st, { text: i.capsDiscard, onYes: refrescar })
1081
+ else await refrescar()
1082
+ return true
1083
+ }
1076
1084
 
1077
1085
  // GUARDAR: UNA sola acta con todo lo que hayas tocado.
1078
1086
  if (ch === 'g') {
package/src/vault.js CHANGED
@@ -798,9 +798,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
798
798
  await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
799
799
  audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
800
800
  log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
801
- // Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
801
+ // Ya puede recibir sobres: se le envuelve la llave de sus cajones en el acto, o
802
802
  // seguiría sin poder abrir nada hasta la siguiente escritura.
803
- await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
803
+ //
804
+ // Va por `recipientsOf` —el único sitio que sabe quién debe tener envoltura de un
805
+ // cajón—, que incluye a los SELLADORES. Con `nsMembers` se envolvía solo para los del
806
+ // ns y los selladores se quedaban con lo viejo (dueño, 2026-09-01).
807
+ //
808
+ // Y su cajón PROPIO también: un aparato con variables suyas se quedaba sin poder
809
+ // abrirlas, que es el mismo agujero por otra puerta.
810
+ for (const owner of [`ns:${ns}`, `dev:${chk.device}`]) {
811
+ await spreadKey(owner, await recipientsOf(owner))
812
+ .catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
813
+ }
804
814
  const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
805
815
  const { signature } = await identity.signData(body)
806
816
  reply(from, { type: MSG.SECRETS_RESULT, body, signature })
@@ -881,6 +891,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
881
891
  const payload = b.legacy
882
892
  ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])), acta: record || null }
883
893
  : { sealed: b, acta: record || null }
894
+ // LO QUE SE MANDA CON AGUJEROS, SE DICE. La bóveda sabe aquí mismo que este aparato no
895
+ // tiene envoltura de tal variable —`missingFor` lo contesta—, y hasta hoy se callaba:
896
+ // contestaba un bundle que el agente no podía abrir y el registro decía «aprobado»,
897
+ // como si se hubiera servido. Desde fuera parecía que el pedido funcionaba y volvía a
898
+ // aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
899
+ // bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
900
+ if (!b.legacy) {
901
+ const faltan = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
902
+ if (faltan.length) {
903
+ const quien = await deviceIdOf(devicePub).catch(() => '????-????')
904
+ audit('secrets.no-wrapping', { device: quien, ns, vars: faltan })
905
+ log(`[vault] ${ns}: ${quien} has no wrapping for ${faltan.join(', ')} — open the vault (dotrino-vault unlock) so it hands it the key`)
906
+ }
907
+ }
884
908
  const enc = await seal({ ek, payload })
885
909
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
886
910
  // LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
@@ -943,7 +967,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
943
967
  if (op === 'deny') {
944
968
  audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
945
969
  log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
946
- try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) } catch (_) {}
970
+ try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) }
971
+ catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
947
972
  return answer({ op: 'deny.result', id, ok: true })
948
973
  }
949
974
  if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
@@ -954,18 +979,42 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
954
979
  audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by })
955
980
  log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
956
981
  // Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
957
- try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) } catch (_) {}
982
+ // EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
983
+ // «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
984
+ // así que el dueño aprobaba una y otra vez creyendo que el teléfono no llegaba.
985
+ try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) }
986
+ catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
958
987
  return answer({ op: 'approve.result', id, ok: true })
959
988
  }
960
989
 
961
- /** Aviso a los aparatos que aprueban (cola del proxio → push nativo si están apagados). */
990
+ /**
991
+ * Aviso a los aparatos que aprueban (cola del proxio → timbre nativo si están apagados).
992
+ *
993
+ * LA MAESTRA NO FIRMA ESTO, y es el fallo que dejaba el mecanismo entero inservible.
994
+ * Avisar es SERVIR, y servir es trabajo de la llave de sellado —la maestra solo sella el
995
+ * acta y reenvuelve los sobres al abrir (CLAUDE.md, «la maestra tiene DOS trabajos»)—.
996
+ * Con `identity.signData` esto reventaba con «vault locked» en cada pedido, o sea SIEMPRE:
997
+ * la bóveda vive cerrada, que es justamente para lo que sirve el candado. Y como reventaba
998
+ * antes de mandar nada, el proxio no tenía a quién encolar y el teléfono NO TIMBRABA
999
+ * NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
1000
+ *
1001
+ * El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
1002
+ * sin contenido: quien lo recibe pregunta por la lista, no la lee del aviso.
1003
+ */
962
1004
  async function notifyApprovers (pend, record) {
963
1005
  try {
964
1006
  const body = { ev: 'approval', id: pend.id, ns: pend.ns, deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
965
- const { signature } = await identity.signData(body)
1007
+ const seal = await sealOrFail(body)
966
1008
  const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
967
1009
  if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
968
- for (const pub of who) { try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {} }
1010
+ // EL FALLO NO SE TRAGA. Un aviso que no sale es un teléfono que no timbra, y eso
1011
+ // desde fuera se ve igual que «nadie ha aprobado todavía»: se espera para siempre
1012
+ // sin nada que mirar. Es el mismo cuidado que en `notifyMembers`.
1013
+ for (const pub of who) {
1014
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }) }
1015
+ catch (e) { log(`[vault] could not ring ${pub.slice(0, 24)}… about ${pend.ns}: ${e.message}`) }
1016
+ }
1017
+ log(`[vault] ${pend.ns}: rang ${who.length} approver(s) for ${pend.id}`)
969
1018
  } catch (e) { log('[vault] could not notify approvers:', e.message) }
970
1019
  }
971
1020
 
@@ -1750,7 +1799,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1750
1799
  */
1751
1800
  async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
1752
1801
  await secrets.set(ns, key, value, isPublic, { by })
1753
- await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1802
+ await settleDebts(`ns:${ns}`)
1754
1803
  audit('secret.set', { ns, key }); scheduleNotice(ns)
1755
1804
  }
1756
1805
 
@@ -1815,8 +1864,79 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1815
1864
  return { done, asked: pending.length }
1816
1865
  }
1817
1866
 
1867
+ /**
1868
+ * REHACER EL LLAVERO ENTERO. Se llama al ABRIR la bóveda, que es el único momento en que
1869
+ * se puede: envolver exige la llave del cajón, y esa solo se abre con la frase.
1870
+ *
1871
+ * Lo que falle aquí SALE en `failed`, y quien llame tiene que decirlo. Callarlo fue el
1872
+ * fallo: un cajón que no se pudo reenvolver deja a sus aparatos sin poder leer nada, y
1873
+ * desde fuera la bóveda parecía abierta y sana — «desbloqueado» a secas—. Se descubría
1874
+ * días después, por un servicio que pedía sus claves una y otra vez.
1875
+ */
1876
+ /**
1877
+ * QUE LOS SOBRES EXISTAN PARA TODOS, SIEMPRE — TAMBIÉN AL SELLAR EL ACTA.
1878
+ *
1879
+ * Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
1880
+ * que todos los sobres para todos los dispositivos existan, antes y después de sellar un
1881
+ * acta; es la tarea de la llave maestra, no tiene otra».
1882
+ *
1883
+ * Y es literalmente su otro trabajo: la maestra firma el acta y regenera los sobres. Todo
1884
+ * cambio del acta cambia QUIÉN debe tener envoltura de qué —entra un aparato, se le da
1885
+ * `secrets`, se le quita, aparece un sellador nuevo—, así que sellar sin repasar los
1886
+ * sobres deja el llavero diciendo una cosa y el acta otra. Eso fue justo el agujero: un
1887
+ * servicio en el acta, con su permiso, y sin poder abrir nada.
1888
+ *
1889
+ * Con la bóveda ABIERTA se rehace de verdad. Cerrada no se puede —envolver exige la llave
1890
+ * del cajón— y entonces queda como DEUDA anotada, que es lo que salda el `unlock`. Lo que
1891
+ * no se hace es callarlo.
1892
+ */
1893
+ /**
1894
+ * QUE LOS SOBRES EXISTAN PARA TODOS, CADA VEZ QUE EL ACTA CAMBIA.
1895
+ *
1896
+ * Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
1897
+ * que todos los sobres para todos los dispositivos existan, antes y después de sellar un
1898
+ * acta; es la tarea de la llave maestra, no tiene otra». Y es literalmente su otro
1899
+ * trabajo: firmar el acta y regenerar los sobres.
1900
+ *
1901
+ * SE REHACE ENTERO, no se salda lo que falta. Saldar añade lo que no está pero no MIRA lo
1902
+ * que sobra: la envoltura de alguien a quien ya se le quitó el permiso se quedaría ahí
1903
+ * para siempre y nadie se enteraría. `resealAll` con `exact` deja cada cajón envuelto para
1904
+ * exactamente quien dice el acta —crea lo que falta y RETIRA lo caduco—, que es la única
1905
+ * forma de poder afirmar que el llavero está al día. No toca la red: es reenvolver con
1906
+ * las públicas del acta, así que esperar aquí no cuelga de ningún aparato apagado.
1907
+ *
1908
+ * NO HAY CAMINO PARA «LA BÓVEDA ESTÁ CERRADA» (dueño, 2026-09-01: «es un absurdo, con
1909
+ * bóveda cerrada no puede cambiar el acta»). Sellar el acta necesita la maestra, y cerrada
1910
+ * la maestra no está en memoria — o sea que si se llegó hasta aquí, estaba abierta. Un
1911
+ * `if` para ese caso no sería prudencia: sería un repliegue que le pone cara de trámite
1912
+ * normal a una contradicción. Si aun así falta la llave, se dice como lo que es.
1913
+ */
1914
+ async function refreshWraps (motivo) {
1915
+ // HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
1916
+ //
1917
+ // `startVault` sin gestor de perfiles trae `hasPassword = () => true` y `openKey =
1918
+ // null`, que leído a la letra dice «tiene contraseña pero no hay con qué abrirla» — el
1919
+ // estado imposible. Mirando solo `hasPassword` se daba por cerrada una bóveda que abre
1920
+ // con la llave de la máquina, y el repaso del llavero se saltaba ENTERO: el servicio se
1921
+ // quedaba sin su envoltura y contestábamos un paquete que no podía abrir. Es el mismo
1922
+ // fallo que se está arreglando, colado por la puerta de al lado.
1923
+ const conCandado = typeof openKey === 'function' && (() => { try { return !!hasPassword() } catch (_) { return true } })()
1924
+ const k = conCandado ? openKey() : null
1925
+ if (conCandado && !k) {
1926
+ log(`[vault] ${motivo}: the record changed but the profile key is not here — the record cannot be sealed while locked, so this should not happen; the keyring was NOT rebuilt`)
1927
+ return null
1928
+ }
1929
+ const r = await resealAll(k).catch((e) => { log(`[vault] ${motivo}: could not rebuild the keyring: ${e.message}`); return null })
1930
+ if (r?.wrapped) log(`[vault] ${motivo}: keyring rebuilt (${r.wrapped} wrap(s) in ${r.drawers} drawer(s))`)
1931
+ // LOS SOBRES CADUCOS SE DICEN. Son envolturas de quien ya no debería poder abrir ese
1932
+ // cajón: retirarlas en silencio esconde que alguien las tuvo de más.
1933
+ if (r?.dropped) log(`[vault] ${motivo}: ${r.dropped} stale wrap(s) dropped (they belonged to members the record no longer names)`)
1934
+ if (r?.failed?.length) log(`[vault] ${motivo}: ${r.failed.length} drawer(s) could NOT be resealed: ${r.failed.map((f) => f.owner).join(', ')}`)
1935
+ return r
1936
+ }
1937
+
1818
1938
  async function resealAll (adminKey = null) {
1819
- const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
1939
+ const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
1820
1940
  for (const owner of secrets.owners?.() || []) {
1821
1941
  const before = new Set(secrets.recipientsIn(owner))
1822
1942
  const members = await recipientsOf(owner)
@@ -1829,9 +1949,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1829
1949
  for (const p of r?.sinLlave || []) out.sinLlave.push(p)
1830
1950
  } catch (e) {
1831
1951
  log(`[vault] ${owner}: could not reseal (${e.message})`)
1952
+ out.failed.push({ owner, error: e.message })
1832
1953
  }
1833
1954
  }
1834
1955
  if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
1956
+ if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
1835
1957
  return out
1836
1958
  }
1837
1959
 
@@ -1871,12 +1993,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1871
1993
  * anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
1872
1994
  * guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
1873
1995
  */
1874
- async function settleDebts (owner, membersFn) {
1996
+ async function settleDebts (owner) {
1875
1997
  const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1876
1998
  const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
1877
1999
  (await incompleteMembers()).some((m) => m.owners[owner])
1878
2000
  if (!owed) return null
1879
- try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
2001
+ // QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
2002
+ //
2003
+ // Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
2004
+ // se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
2005
+ // una variable envolvía para el servicio y dejaba al sellador con la generación vieja
2006
+ // (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
2007
+ // para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
2008
+ // siempre así: una se actualiza y la otra no.
2009
+ try { return await spreadKey(owner, await recipientsOf(owner), null) } catch (_) { return null }
1880
2010
  }
1881
2011
  async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
1882
2012
 
@@ -1914,7 +2044,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1914
2044
  }
1915
2045
  })
1916
2046
  if (changed.length) {
1917
- await settleDebts(`ns:${ns}`, () => nsMembers(ns))
2047
+ await settleDebts(`ns:${ns}`)
1918
2048
  scheduleNotice(ns)
1919
2049
  }
1920
2050
  return changed
@@ -1936,7 +2066,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1936
2066
  }
1937
2067
  })
1938
2068
  if (changed.length) {
1939
- await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
2069
+ await settleDebts(`dev:${pub}`)
1940
2070
  const device = await deviceIdOf(pub).catch(() => null)
1941
2071
  for (const it of list) {
1942
2072
  if (!changed.includes(it.key)) continue
@@ -2015,7 +2145,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2015
2145
  async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
2016
2146
  const m = await requireService(pub)
2017
2147
  await secrets.setDevice(pub, key, value, isPublic, { by })
2018
- await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
2148
+ await settleDebts(`dev:${pub}`)
2019
2149
  audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
2020
2150
  scheduleDeviceNotice(pub)
2021
2151
  }
@@ -2107,7 +2237,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2107
2237
  // el archivo vendorizado en el iframe de identidad.
2108
2238
  const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
2109
2239
  if (m?.cn) {
2110
- await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
2240
+ // POR `recipientsOf`, NO por `nsMembers`. Quien debe tener envoltura de un cajón lo
2241
+ // dice UN sitio, y ahí dentro están también los SELLADORES (dueño, 2026-09-01: «al
2242
+ // envolver hay que envolver siempre para todos los selladores, para que siempre
2243
+ // tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
2244
+ // puerta dejaba a los selladores con la envoltura vieja.
2245
+ await spreadKey(`ns:${m.cn}`, await recipientsOf(`ns:${m.cn}`), adminKey).catch(async (e) => {
2111
2246
  log('[vault] could not hand the key to the new service:', e.message)
2112
2247
  // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
2113
2248
  // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
@@ -2117,6 +2252,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2117
2252
  if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
2118
2253
  })
2119
2254
  }
2255
+ // Y el llavero ENTERO: el recién llegado también es destinatario de su propio cajón,
2256
+ // y si entra con `sella` lo es de TODOS. Repartir solo el suyo dejaba el resto atrás.
2257
+ await refreshWraps('enrolled')
2120
2258
  await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
2121
2259
  return r
2122
2260
  },
@@ -2161,6 +2299,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2161
2299
  // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
2162
2300
  rotationsDue,
2163
2301
  secretDebts,
2302
+ /**
2303
+ * QUIÉN tiene envoltura de la generación vigente de un cajón (llaves de firma, más
2304
+ * `#recovery`). Es diagnóstico: saber a cuántos se les envolvió no ayuda a abrir nada,
2305
+ * y en cambio es lo único que contesta de verdad a «¿quién puede leer esto?».
2306
+ *
2307
+ * Se expone porque hacía falta para COMPROBARLO en una prueba en vez de argumentarlo:
2308
+ * la condición del dueño para acotar los sobres por cajón y permisos —en vez de
2309
+ * envolver para todos— fue estar seguro de que no falta ninguno.
2310
+ */
2311
+ secretRecipients: (owner) => secrets.recipientsIn(owner),
2164
2312
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
2165
2313
  isMaster: () => identity.isMaster(),
2166
2314
  setCaps: async (pub, caps) => {
@@ -2178,6 +2326,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2178
2326
  }
2179
2327
  } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
2180
2328
  audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
2329
+ // El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
2330
+ // avisar, para que el aviso salga con el llavero ya al día.
2331
+ await refreshWraps('caps')
2181
2332
  await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
2182
2333
  return r
2183
2334
  },
@@ -2193,6 +2344,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2193
2344
  // QUITAR EL DISPOSITIVO. Se identifica por su llave (`sub`), no por un `nonce`: un
2194
2345
  // aparato puede tener varios certificados y retirar uno no lo echaba de la bóveda.
2195
2346
  // Se sigue aceptando `{ nonce }` para no romper una consola vieja en vuelo.
2347
+ //
2348
+ // Al salir alguien el acta cambia igual que al entrar, así que el llavero se repasa
2349
+ // también aquí: lo que sobra se quita (`resealAll` lo hace) y el que se queda sigue
2350
+ // completo. Un llavero que solo se repasa al añadir acumula envolturas de quien ya no
2351
+ // está, que es lo contrario de lo que se quiso al expulsarlo.
2196
2352
  revokeDevice: async (target) => {
2197
2353
  const sub = typeof target === 'object' && target ? target.sub : null
2198
2354
  if (!sub) {
@@ -2203,6 +2359,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2203
2359
  }
2204
2360
  const r = await desk.revokeDevice(sub)
2205
2361
  audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
2362
+ await refreshWraps('revoked')
2206
2363
  await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
2207
2364
  return r
2208
2365
  },