@dotrino/vaultd 0.86.0 → 0.87.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.86.0",
3
+ "version": "0.87.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": {
@@ -296,8 +296,13 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
296
296
  }
297
297
 
298
298
  /** Los destinatarios de un cajón: los que dice el acta, más la copia de recuperación. */
299
- const wrapAll = async (cek, owner) => {
300
- const members = (await recipients?.(owner)) || []
299
+ /**
300
+ * @param {boolean} isPublic Una PÚBLICA se envuelve además para quien administra, aunque
301
+ * el cajón tenga dueño (dueño, 2026-09-02). Puede variar por variable porque cada
302
+ * escritura estrena su propia CEK: el llavero guarda una generación por valor.
303
+ */
304
+ const wrapAll = async (cek, owner, isPublic = false) => {
305
+ const members = (await recipients?.(owner, { public: isPublic })) || []
301
306
  const { wraps, sinLlave } = await sealer.wrapFor(cek, members)
302
307
  wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
303
308
  return { wraps, sinLlave }
@@ -537,7 +542,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
537
542
  // CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
538
543
  const cek = await sealer.newKey()
539
544
  const gen = (topGen(bag, k)?.gen || 0) + 1
540
- const { wraps, sinLlave } = await wrapAll(cek, owner)
545
+ const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
541
546
  const e = await sealer.encrypt(cek, value, gen)
542
547
  // La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
543
548
  // hay con qué firmar, el sobre sale sin firma: guardar es más importante.
@@ -631,9 +636,9 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
631
636
  * haya dos sitios respondiendo a «quién puede abrir este cajón» (la lista la sabe el
632
637
  * acta, y de ahí sale `recipients`).
633
638
  */
634
- async recipientsFor (owner) {
639
+ async recipientsFor (owner, { public: isPublic = false } = {}) {
635
640
  await ensureRecovery(null)
636
- const members = (await recipients?.(owner)) || []
641
+ const members = (await recipients?.(owner, { public: isPublic })) || []
637
642
  return {
638
643
  recoveryPub: data.recovery.pub,
639
644
  members: members.filter((m) => m.encPub).map((m) => ({ pub: m.pub, encPub: m.encPub }))
@@ -796,6 +801,13 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
796
801
  return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
797
802
  },
798
803
  /** @private */
804
+ /**
805
+ * @param {Array|((isPublic:boolean)=>Promise<Array>)} members A quién envolver. Puede ser
806
+ * una FUNCIÓN de la visibilidad: desde 2026-09-02 una pública se envuelve además para
807
+ * quien administra, y eso se decide por variable — cada escritura estrena su CEK, así
808
+ * que el llavero tiene una generación por valor. Con una lista fija se aplicaría la
809
+ * misma a todas y se le daría a la consola la envoltura de una privada con dueño.
810
+ */
799
811
  async _rewrap (owner, members, adminKey = null, { exact = false } = {}) {
800
812
  needSealer('re-wrap the key of a drawer')
801
813
  if (needsMigration()) throw new NeedsMigration()
@@ -805,11 +817,15 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
805
817
  const priv = await openRecovery(adminKey)
806
818
  let wrapped = 0
807
819
  const sinLlave = new Set()
820
+ // De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
821
+ const visibilidad = new Map()
822
+ for (const e of Object.values(varsOf(bag, k))) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
823
+ const paraGen = async (gen) => (typeof members === 'function' ? members(visibilidad.get(gen) === true) : members)
808
824
  for (const g of bag[k].keyring || []) {
809
825
  const w = g.wraps?.[RECOVERY]
810
826
  if (!w) continue
811
827
  const cek = await sealer.openWrapWith(priv, w)
812
- const r = await sealer.wrapFor(cek, members)
828
+ const r = await sealer.wrapFor(cek, await paraGen(g.gen))
813
829
  // `exact`: el llavero queda con lo que dice el acta y NADA más. Es lo que hace
814
830
  // falta para rehacerlo al abrir la bóveda:
815
831
  // · una envoltura basura que metió alguien se reemplaza por la buena;
package/src/vault.js CHANGED
@@ -197,7 +197,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
197
197
  // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
198
198
  // propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
199
199
  // acta, y por eso lo pone el vault: el store no conoce el acta.
200
- recipients: (owner) => recipientsOf(owner),
200
+ recipients: (owner, opts) => recipientsOf(owner, opts),
201
201
  // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
202
202
  signer: (body) => signSeal(body),
203
203
  // CON QUÉ se cierra la copia de recuperación cuando quien llama no trajo la frase:
@@ -808,7 +808,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
808
808
  // Y su cajón PROPIO también: un aparato con variables suyas se quedaba sin poder
809
809
  // abrirlas, que es el mismo agujero por otra puerta.
810
810
  for (const owner of [`ns:${ns}`, `dev:${chk.device}`]) {
811
- await spreadKey(owner, await recipientsOf(owner))
811
+ await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }))
812
812
  .catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
813
813
  }
814
814
  const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
@@ -1386,8 +1386,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1386
1386
  * (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
1387
1387
  * exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
1388
1388
  */
1389
- async recipients ({ ns, pub }) {
1390
- return secrets.recipientsFor(ns ? `ns:${ns}` : `dev:${pub}`)
1389
+ async recipients ({ ns, pub, public: isPublic }) {
1390
+ // LA VISIBILIDAD CAMBIA LA LISTA: una pública se envuelve además para quien
1391
+ // administra, así que hay que decirlo al preguntar. Pedirla sin decirlo y luego
1392
+ // guardarla como pública produciría un sobre que la consola no puede abrir — y la
1393
+ // bóveda lo rechazaría, que es lo correcto pero desorienta.
1394
+ return secrets.recipientsFor(ns ? `ns:${ns}` : `dev:${pub}`, { public: !!isPublic })
1391
1395
  },
1392
1396
  /**
1393
1397
  * GUARDAR UNA VARIABLE **SIN VERLA** (dueño, 2026-09-01).
@@ -1416,7 +1420,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1416
1420
  // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
1417
1421
  // marca solo decide si se entrega sin aprobación.
1418
1422
  await verificarAutor(owner, key, sealed, caller)
1419
- await verificarDestinatarios(owner, sealed.wraps)
1423
+ await verificarDestinatarios(owner, sealed.wraps, !!isPublic)
1420
1424
  await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
1421
1425
  audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
1422
1426
  await settleDebts(owner)
@@ -1456,7 +1460,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1456
1460
  for (const it of items) {
1457
1461
  if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
1458
1462
  await verificarAutor(owner, it.key, it.sealed, caller)
1459
- await verificarDestinatarios(owner, it.sealed.wraps)
1463
+ await verificarDestinatarios(owner, it.sealed.wraps, !!it.public)
1460
1464
  }
1461
1465
  // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
1462
1466
  // configuración que nadie quiso, y el servicio se reinicia con ella.
@@ -1660,24 +1664,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1660
1664
  }
1661
1665
 
1662
1666
  /** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
1667
+ /** Sin repetidos por llave: un aparato puede entrar por dos vías (dueño y admin). */
1668
+ const dedup = (miembros) => {
1669
+ const vistos = new Set()
1670
+ return miembros.filter((m) => m && !vistos.has(m.pub) && vistos.add(m.pub))
1671
+ }
1663
1672
  const conCoselladores = (base, co) => {
1664
1673
  const vistos = new Set(base.map((m) => m.pub))
1665
1674
  return [...base, ...co.filter((m) => !vistos.has(m.pub))]
1666
1675
  }
1667
1676
 
1668
- async function recipientsOf (owner) {
1677
+ async function recipientsOf (owner, { public: isPublic = false } = {}) {
1669
1678
  const co = await cosealerMembers()
1679
+ // UNA PÚBLICA SE ENVUELVE TAMBIÉN PARA QUIEN ADMINISTRA (dueño, 2026-09-02: «la consola
1680
+ // debe poder leer todas las variables… debe tener todos los sobres; la diferencia es si
1681
+ // el vault se los envía para la lectura o no» → «cambia la regla y di que se envuelven
1682
+ // las públicas»).
1683
+ //
1684
+ // Puede decidirse variable a variable porque cada escritura estrena su propia CEK: el
1685
+ // llavero guarda una generación por valor, no una por cajón.
1686
+ //
1687
+ // Y la PRIVADA de un cajón con dueño sigue sin envoltura para la consola, que es la
1688
+ // regla de agosto y su motivo no ha cambiado: el token de R2 no tiene por qué poder
1689
+ // abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
1690
+ // para quien administra — puede leerla— además de que se despacha sin aprobación.
1691
+ const admins = isPublic ? await adminDevices() : []
1670
1692
  if (owner.startsWith('ns:')) {
1671
1693
  const owned = await nsMembers(owner.slice(3))
1672
- // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
1673
- // administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
1674
- return conCoselladores(owned.length ? owned : await adminDevices(), co)
1694
+ // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
1695
+ // SIEMPRE: si no, no quedaría nadie que pudiera abrirlo sin la frase.
1696
+ const base = owned.length ? [...owned, ...admins] : await adminDevices()
1697
+ return conCoselladores(dedup(base), co)
1675
1698
  }
1676
1699
  const pub = owner.slice(owner.indexOf(':') + 1)
1677
1700
  const m = await memberOf(pub)
1678
1701
  // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
1679
- if (m?.cn) return conCoselladores([m], co)
1680
- return conCoselladores([...(m ? [m] : []), ...await adminDevices()], co)
1702
+ if (m?.cn) return conCoselladores(dedup([m, ...admins]), co)
1703
+ return conCoselladores(dedup([...(m ? [m] : []), ...await adminDevices()]), co)
1681
1704
  }
1682
1705
 
1683
1706
  /**
@@ -2052,9 +2075,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2052
2075
  *
2053
2076
  * Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
2054
2077
  */
2055
- async function verificarDestinatarios (owner, wraps) {
2078
+ async function verificarDestinatarios (owner, wraps, isPublic = false) {
2056
2079
  if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
2057
- const { recoveryPub, members } = await secrets.recipientsFor(owner)
2080
+ const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
2058
2081
  if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
2059
2082
  const deben = new Set(members.map((m) => m.pub))
2060
2083
  const traidos = new Set(Object.keys(wraps).filter((k) => k !== RECOVERY_WRAP))
@@ -2146,7 +2169,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2146
2169
  const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
2147
2170
  for (const owner of secrets.owners?.() || []) {
2148
2171
  const before = new Set(secrets.recipientsIn(owner))
2149
- const members = await recipientsOf(owner)
2172
+ // POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
2173
+ // administra. Con una sola lista se le daría también la de una privada con dueño, que
2174
+ // es justo lo que no puede pasar.
2175
+ const members = (esPublica) => recipientsOf(owner, { public: esPublica })
2150
2176
  try {
2151
2177
  const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
2152
2178
  const after = new Set(secrets.recipientsIn(owner))
@@ -2179,7 +2205,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2179
2205
  // Los destinatarios de un cajón no son solo quien lo consume: también quien lo
2180
2206
  // administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
2181
2207
  // sin poder destapar lo que ella misma acababa de saldar.
2182
- const members = await recipientsOf(owner)
2208
+ const members = (esPublica) => recipientsOf(owner, { public: esPublica })
2183
2209
  try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
2184
2210
  // Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
2185
2211
  let delegated = 0
@@ -2213,7 +2239,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2213
2239
  // (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
2214
2240
  // para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
2215
2241
  // siempre así: una se actualiza y la otra no.
2216
- try { return await spreadKey(owner, await recipientsOf(owner), null) } catch (_) { return null }
2242
+ try { return await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }), null) } catch (_) { return null }
2217
2243
  }
2218
2244
  async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
2219
2245
 
@@ -2448,7 +2474,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2448
2474
  // envolver hay que envolver siempre para todos los selladores, para que siempre
2449
2475
  // tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
2450
2476
  // puerta dejaba a los selladores con la envoltura vieja.
2451
- await spreadKey(`ns:${m.cn}`, await recipientsOf(`ns:${m.cn}`), adminKey).catch(async (e) => {
2477
+ await spreadKey(`ns:${m.cn}`, (esPublica) => recipientsOf(`ns:${m.cn}`, { public: esPublica }), adminKey).catch(async (e) => {
2452
2478
  log('[vault] could not hand the key to the new service:', e.message)
2453
2479
  // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
2454
2480
  // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el