@dotrino/vaultd 0.80.2 → 0.82.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.2",
3
+ "version": "0.82.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
  }
@@ -796,6 +796,19 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
796
796
  * `null` en cualquiera de las dos significa «la del perfil sin contraseña».
797
797
  */
798
798
  async rekeyRecovery (...a) { return enFila(() => this._rekeyRecovery(...a)) },
799
+ /**
800
+ * ¿ESTA llave abre la copia de recuperación? Lanza si no.
801
+ *
802
+ * Sirve para saber con cuál está cerrada sin tocar nada — que es lo que hace falta para
803
+ * decidir si hay que migrarla. Preguntarlo intentando reenvolver un cajón mezcla dos
804
+ * cosas y deja el fallo diciendo «wrong password» sobre el cajón, que no es donde está.
805
+ */
806
+ async recoveryOpensWith (key) {
807
+ needSealer('check the recovery key')
808
+ if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
809
+ await sealer.openMaster(data.recovery.priv, keyOr(key))
810
+ return true
811
+ },
799
812
  /** @private */
800
813
  async _rekeyRecovery (oldKey, newKey) {
801
814
  if (isLegacy()) return { rekeyed: false, reason: 'v3' }
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
 
@@ -1777,6 +1826,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1777
1826
  *
1778
1827
  * @returns {Promise<{ done: number, asked: number }>}
1779
1828
  */
1829
+ /**
1830
+ * PEDIRLE A UN HERMANO QUE ENVUELVA — Y QUE SE LO ENTREGUE A LA BÓVEDA (§8.11).
1831
+ *
1832
+ * Regla del dueño (2026-09-01): «un servicio reparte la llave AL VAULT para que este la
1833
+ * reparta después, pero no se la entrega directo; si lo hace sin pasar por el vault está
1834
+ * mal». Es lo que hace este camino, y conviene verlo en el orden de las líneas:
1835
+ *
1836
+ * 1. la BÓVEDA le pide el sobre al hermano (`sendByPubkey` → `REWRAP`);
1837
+ * 2. el hermano le contesta A ELLA, nunca al recién llegado;
1838
+ * 3. la BÓVEDA lo guarda (`putWrap`) y a partir de ahí lo sirve ella.
1839
+ *
1840
+ * El destinatario no habla con nadie, y la bóveda nunca ve la llave en claro: el sobre
1841
+ * llega ya cerrado a la pública del destinatario. Por eso esto funciona con el perfil
1842
+ * cerrado, que es justo cuando hace falta.
1843
+ */
1780
1844
  async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
1781
1845
  const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1782
1846
  if (!ns) return { done: 0, asked: 0 }
@@ -1815,8 +1879,122 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1815
1879
  return { done, asked: pending.length }
1816
1880
  }
1817
1881
 
1882
+ /**
1883
+ * REHACER EL LLAVERO ENTERO. Se llama al ABRIR la bóveda, que es el único momento en que
1884
+ * se puede: envolver exige la llave del cajón, y esa solo se abre con la frase.
1885
+ *
1886
+ * Lo que falle aquí SALE en `failed`, y quien llame tiene que decirlo. Callarlo fue el
1887
+ * fallo: un cajón que no se pudo reenvolver deja a sus aparatos sin poder leer nada, y
1888
+ * desde fuera la bóveda parecía abierta y sana — «desbloqueado» a secas—. Se descubría
1889
+ * días después, por un servicio que pedía sus claves una y otra vez.
1890
+ */
1891
+ /**
1892
+ * QUE LOS SOBRES EXISTAN PARA TODOS, SIEMPRE — TAMBIÉN AL SELLAR EL ACTA.
1893
+ *
1894
+ * Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
1895
+ * que todos los sobres para todos los dispositivos existan, antes y después de sellar un
1896
+ * acta; es la tarea de la llave maestra, no tiene otra».
1897
+ *
1898
+ * Y es literalmente su otro trabajo: la maestra firma el acta y regenera los sobres. Todo
1899
+ * cambio del acta cambia QUIÉN debe tener envoltura de qué —entra un aparato, se le da
1900
+ * `secrets`, se le quita, aparece un sellador nuevo—, así que sellar sin repasar los
1901
+ * sobres deja el llavero diciendo una cosa y el acta otra. Eso fue justo el agujero: un
1902
+ * servicio en el acta, con su permiso, y sin poder abrir nada.
1903
+ *
1904
+ * Con la bóveda ABIERTA se rehace de verdad. Cerrada no se puede —envolver exige la llave
1905
+ * del cajón— y entonces queda como DEUDA anotada, que es lo que salda el `unlock`. Lo que
1906
+ * no se hace es callarlo.
1907
+ */
1908
+ /**
1909
+ * QUE LOS SOBRES EXISTAN PARA TODOS, CADA VEZ QUE EL ACTA CAMBIA.
1910
+ *
1911
+ * Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
1912
+ * que todos los sobres para todos los dispositivos existan, antes y después de sellar un
1913
+ * acta; es la tarea de la llave maestra, no tiene otra». Y es literalmente su otro
1914
+ * trabajo: firmar el acta y regenerar los sobres.
1915
+ *
1916
+ * SE REHACE ENTERO, no se salda lo que falta. Saldar añade lo que no está pero no MIRA lo
1917
+ * que sobra: la envoltura de alguien a quien ya se le quitó el permiso se quedaría ahí
1918
+ * para siempre y nadie se enteraría. `resealAll` con `exact` deja cada cajón envuelto para
1919
+ * exactamente quien dice el acta —crea lo que falta y RETIRA lo caduco—, que es la única
1920
+ * forma de poder afirmar que el llavero está al día. No toca la red: es reenvolver con
1921
+ * las públicas del acta, así que esperar aquí no cuelga de ningún aparato apagado.
1922
+ *
1923
+ * NO HAY CAMINO PARA «LA BÓVEDA ESTÁ CERRADA» (dueño, 2026-09-01: «es un absurdo, con
1924
+ * bóveda cerrada no puede cambiar el acta»). Sellar el acta necesita la maestra, y cerrada
1925
+ * la maestra no está en memoria — o sea que si se llegó hasta aquí, estaba abierta. Un
1926
+ * `if` para ese caso no sería prudencia: sería un repliegue que le pone cara de trámite
1927
+ * normal a una contradicción. Si aun así falta la llave, se dice como lo que es.
1928
+ */
1929
+ async function refreshWraps (motivo) {
1930
+ // HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
1931
+ //
1932
+ // `startVault` sin gestor de perfiles trae `hasPassword = () => true` y `openKey =
1933
+ // null`, que leído a la letra dice «tiene contraseña pero no hay con qué abrirla» — el
1934
+ // estado imposible. Mirando solo `hasPassword` se daba por cerrada una bóveda que abre
1935
+ // con la llave de la máquina, y el repaso del llavero se saltaba ENTERO: el servicio se
1936
+ // quedaba sin su envoltura y contestábamos un paquete que no podía abrir. Es el mismo
1937
+ // fallo que se está arreglando, colado por la puerta de al lado.
1938
+ const conCandado = typeof openKey === 'function' && (() => { try { return !!hasPassword() } catch (_) { return true } })()
1939
+ const k = conCandado ? openKey() : null
1940
+ if (conCandado && !k) {
1941
+ 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`)
1942
+ return null
1943
+ }
1944
+ const r = await resealAll(k).catch((e) => { log(`[vault] ${motivo}: could not rebuild the keyring: ${e.message}`); return null })
1945
+ if (r?.wrapped) log(`[vault] ${motivo}: keyring rebuilt (${r.wrapped} wrap(s) in ${r.drawers} drawer(s))`)
1946
+ // LOS SOBRES CADUCOS SE DICEN. Son envolturas de quien ya no debería poder abrir ese
1947
+ // cajón: retirarlas en silencio esconde que alguien las tuvo de más.
1948
+ if (r?.dropped) log(`[vault] ${motivo}: ${r.dropped} stale wrap(s) dropped (they belonged to members the record no longer names)`)
1949
+ if (r?.failed?.length) log(`[vault] ${motivo}: ${r.failed.length} drawer(s) could NOT be resealed: ${r.failed.map((f) => f.owner).join(', ')}`)
1950
+ return r
1951
+ }
1952
+
1953
+ /**
1954
+ * MIGRACIÓN, CON FECHA: la copia de recuperación pudo quedar sellada con la llave de la
1955
+ * MÁQUINA aunque el perfil tenga contraseña.
1956
+ *
1957
+ * Pasa por un camino perfectamente normal: escribir una variable NO pide la frase (§8.1),
1958
+ * así que si el cajón se estrenó con el perfil cerrado, `ensureRecovery` la selló con lo
1959
+ * único que había — la llave de la máquina. A partir de ahí, abrir el perfil y reenvolver
1960
+ * pasa la llave de la CONTRASEÑA, que no abre ese sobre: «wrong password», el llavero no
1961
+ * se rehace, y los servicios se quedan sin poder leer sus cajones para siempre. Es
1962
+ * exactamente lo que le pasó a un perfil real el 2026-09-01.
1963
+ *
1964
+ * Aquí se abre con la de la máquina y se vuelve a cerrar con la de la contraseña, que es
1965
+ * el `rekey` que aquel `password-set` no llegó a hacer. NO es un repliegue: no decide un
1966
+ * permiso, no deja pasar a nadie, y **se acaba** — al primer desbloqueo el sobre queda
1967
+ * bajo la frase y esta rama no vuelve a entrar. Y va en el sentido estricto: el material
1968
+ * de la llave de máquina vive en este mismo disco, así que pasar a la contraseña PROTEGE
1969
+ * más de lo que había, no menos.
1970
+ *
1971
+ * Se puede quitar cuando no queden perfiles de antes de 0.81.0 (2027-03-01).
1972
+ */
1973
+ const MIGRACION_RECUPERACION_HASTA = Date.parse('2027-03-01T00:00:00Z')
1974
+ async function migrarRecuperacionALaFrase (adminKey) {
1975
+ if (!adminKey || Date.now() > MIGRACION_RECUPERACION_HASTA) return false
1976
+ try {
1977
+ // Si la frase ya la abre, no hay nada que migrar.
1978
+ await secrets.recoveryOpensWith(adminKey)
1979
+ return false
1980
+ } catch (_) {}
1981
+ try {
1982
+ const r = await secrets.rekeyRecovery(null, adminKey) // `null` = la llave de la máquina
1983
+ if (!r?.rekeyed) return false
1984
+ log('[vault] the recovery copy was sealed with this machine\'s key: re-sealed under the profile password')
1985
+ audit('secret.recovery-rekeyed', {})
1986
+ return true
1987
+ } catch (e) {
1988
+ log(`[vault] the recovery copy does not open with the password nor with this machine's key (${e.message}): the drawers cannot be re-wrapped`)
1989
+ return false
1990
+ }
1991
+ }
1992
+
1818
1993
  async function resealAll (adminKey = null) {
1819
- const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
1994
+ // ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
1995
+ // máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
1996
+ if (adminKey) await migrarRecuperacionALaFrase(adminKey)
1997
+ const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
1820
1998
  for (const owner of secrets.owners?.() || []) {
1821
1999
  const before = new Set(secrets.recipientsIn(owner))
1822
2000
  const members = await recipientsOf(owner)
@@ -1829,9 +2007,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1829
2007
  for (const p of r?.sinLlave || []) out.sinLlave.push(p)
1830
2008
  } catch (e) {
1831
2009
  log(`[vault] ${owner}: could not reseal (${e.message})`)
2010
+ out.failed.push({ owner, error: e.message })
1832
2011
  }
1833
2012
  }
1834
2013
  if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
2014
+ if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
1835
2015
  return out
1836
2016
  }
1837
2017
 
@@ -1871,12 +2051,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1871
2051
  * anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
1872
2052
  * guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
1873
2053
  */
1874
- async function settleDebts (owner, membersFn) {
2054
+ async function settleDebts (owner) {
1875
2055
  const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1876
2056
  const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
1877
2057
  (await incompleteMembers()).some((m) => m.owners[owner])
1878
2058
  if (!owed) return null
1879
- try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
2059
+ // QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
2060
+ //
2061
+ // Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
2062
+ // se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
2063
+ // una variable envolvía para el servicio y dejaba al sellador con la generación vieja
2064
+ // (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
2065
+ // para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
2066
+ // siempre así: una se actualiza y la otra no.
2067
+ try { return await spreadKey(owner, await recipientsOf(owner), null) } catch (_) { return null }
1880
2068
  }
1881
2069
  async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
1882
2070
 
@@ -1914,7 +2102,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1914
2102
  }
1915
2103
  })
1916
2104
  if (changed.length) {
1917
- await settleDebts(`ns:${ns}`, () => nsMembers(ns))
2105
+ await settleDebts(`ns:${ns}`)
1918
2106
  scheduleNotice(ns)
1919
2107
  }
1920
2108
  return changed
@@ -1936,7 +2124,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1936
2124
  }
1937
2125
  })
1938
2126
  if (changed.length) {
1939
- await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
2127
+ await settleDebts(`dev:${pub}`)
1940
2128
  const device = await deviceIdOf(pub).catch(() => null)
1941
2129
  for (const it of list) {
1942
2130
  if (!changed.includes(it.key)) continue
@@ -2015,7 +2203,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2015
2203
  async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
2016
2204
  const m = await requireService(pub)
2017
2205
  await secrets.setDevice(pub, key, value, isPublic, { by })
2018
- await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
2206
+ await settleDebts(`dev:${pub}`)
2019
2207
  audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
2020
2208
  scheduleDeviceNotice(pub)
2021
2209
  }
@@ -2107,7 +2295,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2107
2295
  // el archivo vendorizado en el iframe de identidad.
2108
2296
  const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
2109
2297
  if (m?.cn) {
2110
- await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
2298
+ // POR `recipientsOf`, NO por `nsMembers`. Quien debe tener envoltura de un cajón lo
2299
+ // dice UN sitio, y ahí dentro están también los SELLADORES (dueño, 2026-09-01: «al
2300
+ // envolver hay que envolver siempre para todos los selladores, para que siempre
2301
+ // tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
2302
+ // puerta dejaba a los selladores con la envoltura vieja.
2303
+ await spreadKey(`ns:${m.cn}`, await recipientsOf(`ns:${m.cn}`), adminKey).catch(async (e) => {
2111
2304
  log('[vault] could not hand the key to the new service:', e.message)
2112
2305
  // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
2113
2306
  // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
@@ -2117,6 +2310,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2117
2310
  if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
2118
2311
  })
2119
2312
  }
2313
+ // Y el llavero ENTERO: el recién llegado también es destinatario de su propio cajón,
2314
+ // y si entra con `sella` lo es de TODOS. Repartir solo el suyo dejaba el resto atrás.
2315
+ await refreshWraps('enrolled')
2120
2316
  await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
2121
2317
  return r
2122
2318
  },
@@ -2161,6 +2357,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2161
2357
  // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
2162
2358
  rotationsDue,
2163
2359
  secretDebts,
2360
+ /**
2361
+ * QUIÉN tiene envoltura de la generación vigente de un cajón (llaves de firma, más
2362
+ * `#recovery`). Es diagnóstico: saber a cuántos se les envolvió no ayuda a abrir nada,
2363
+ * y en cambio es lo único que contesta de verdad a «¿quién puede leer esto?».
2364
+ *
2365
+ * Se expone porque hacía falta para COMPROBARLO en una prueba en vez de argumentarlo:
2366
+ * la condición del dueño para acotar los sobres por cajón y permisos —en vez de
2367
+ * envolver para todos— fue estar seguro de que no falta ninguno.
2368
+ */
2369
+ secretRecipients: (owner) => secrets.recipientsIn(owner),
2164
2370
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
2165
2371
  isMaster: () => identity.isMaster(),
2166
2372
  setCaps: async (pub, caps) => {
@@ -2178,6 +2384,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2178
2384
  }
2179
2385
  } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
2180
2386
  audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
2387
+ // El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
2388
+ // avisar, para que el aviso salga con el llavero ya al día.
2389
+ await refreshWraps('caps')
2181
2390
  await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
2182
2391
  return r
2183
2392
  },
@@ -2193,6 +2402,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2193
2402
  // QUITAR EL DISPOSITIVO. Se identifica por su llave (`sub`), no por un `nonce`: un
2194
2403
  // aparato puede tener varios certificados y retirar uno no lo echaba de la bóveda.
2195
2404
  // Se sigue aceptando `{ nonce }` para no romper una consola vieja en vuelo.
2405
+ //
2406
+ // Al salir alguien el acta cambia igual que al entrar, así que el llavero se repasa
2407
+ // también aquí: lo que sobra se quita (`resealAll` lo hace) y el que se queda sigue
2408
+ // completo. Un llavero que solo se repasa al añadir acumula envolturas de quien ya no
2409
+ // está, que es lo contrario de lo que se quiso al expulsarlo.
2196
2410
  revokeDevice: async (target) => {
2197
2411
  const sub = typeof target === 'object' && target ? target.sub : null
2198
2412
  if (!sub) {
@@ -2203,6 +2417,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2203
2417
  }
2204
2418
  const r = await desk.revokeDevice(sub)
2205
2419
  audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
2420
+ await refreshWraps('revoked')
2206
2421
  await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
2207
2422
  return r
2208
2423
  },