@dotrino/vaultd 0.85.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.85.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": {
package/src/ctl.js CHANGED
@@ -960,7 +960,9 @@ function problemWith (key, value) {
960
960
  }
961
961
 
962
962
  async function cmdSecret (rest) {
963
- // --public / --private: si el VALOR puede salir de esta máquina hacia la consola remota.
963
+ // --public / --private: si al entregarla se pide APROBACIÓN. NO cambia cómo se guarda —
964
+ // desde 2026-09-02 las dos van selladas igual (dueño: «la única diferencia es si se
965
+ // despachan o no, son políticas; dales el mismo tratamiento de seguridad»).
964
966
  // Se sacan de la línea antes de partirla, para que puedan ir en cualquier posición y no
965
967
  // se cuelen dentro del valor (que es lo último y puede llevar espacios).
966
968
  let isPublic
@@ -1054,8 +1056,8 @@ async function cmdSecret (rest) {
1054
1056
  'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
1055
1057
  'cambio de configuración y el servicio se reinicia a media carga.',
1056
1058
  '',
1057
- 'Pública o privada dice UNA cosa: si el VALOR puede salir de esta máquina hacia la',
1058
- 'consola remota (vault.dotrino.com). Se nace privada. El servicio recibe las dos igual.'
1059
+ 'Pública o privada dice UNA cosa: si al entregarla se te pide APROBACIÓN. Las dos se',
1060
+ 'guardan cifradas igual y ninguna enseña su valor en la consola. Se nace privada.'
1059
1061
  ].join('\n')
1060
1062
 
1061
1063
  /**
@@ -1108,11 +1110,14 @@ async function cmdSecret (rest) {
1108
1110
  }
1109
1111
 
1110
1112
  /**
1111
- * Una variable en la lista: su nombre y su valor. La PÚBLICA enseña el suyo (pública
1112
- * quiere decir que ese valor puede salir de esta máquina: taparlo aquí, delante de su
1113
- * dueño, era lo único que la marca no significaba). La privada no se muestra.
1113
+ * Una variable en la lista: su nombre, y que TIENE valor. Ninguna lo enseña.
1114
+ *
1115
+ * La pública lo enseñaba, porque se guardaba en claro. Desde 2026-09-02 las dos van
1116
+ * selladas igual y `pública` solo dice que se entrega sin pedir aprobación (dueño: «la
1117
+ * única diferencia es si se despachan o no, son políticas»). Enseñar el hueco vacío de
1118
+ * una pública era peor que no enseñar nada: parecía que se había perdido.
1114
1119
  */
1115
- const printVar = (k) => console.log(' · %s %s', k.key, k.public ? `${k.value ?? ''} (pública)` : '••••••')
1120
+ const printVar = (k) => console.log(' · %s ••••••%s', k.key, k.public ? ' (pública)' : '')
1116
1121
  /**
1117
1122
  * Por qué no se aplicó. El daemon deja el motivo en el volcado siguiente; sin él lo
1118
1123
  * único que se podía decir era «revisa los logs», que no ayuda a quien acaba de
@@ -1179,7 +1184,10 @@ async function cmdSecret (rest) {
1179
1184
  console.log('%s%s%s%s', B, x.id, Z, who ? ' ' + who : '')
1180
1185
  for (const k of x.keys) printVar(k)
1181
1186
  }
1182
- console.log('\n(pública) = su valor se puede ver desde la consola remota. Las demás no salen de aquí.\n')
1187
+ // QUÉ SIGNIFICA HOY (dueño, 2026-09-02): todas se guardan selladas igual, y `pública`
1188
+ // solo dice a quién se le entrega SIN APROBACIÓN. Decir «su valor se puede ver» era
1189
+ // verdad cuando se guardaba en claro; dejarlo ahí sería prometer algo que ya no pasa.
1190
+ console.log('\n(pública) = se entrega sin pedirte aprobación. Todas se guardan cifradas igual.\n')
1183
1191
  printPending(d)
1184
1192
  await printNoPassword()
1185
1193
  return
@@ -1534,9 +1542,9 @@ function help () {
1534
1542
  vuelve a una versión anterior
1535
1543
  secret settle salda lo pendiente: hereda lo ya guardado a un
1536
1544
  aparato nuevo y rota de verdad el cajón del que se fue
1537
- --public | --private (al hacer un set) si el VALOR puede salir de esta
1538
- máquina hacia la consola remota. Se nace privada,
1539
- y una privada NO se vuelve pública (bórrala y créala).
1545
+ --public | --private (al hacer un set) si al entregarla se te pide
1546
+ APROBACIÓN. Las dos se guardan cifradas igual. Se nace
1547
+ privada, y una privada NO se vuelve pública.
1540
1548
  secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
1541
1549
  secret device visibility <ID> <CLAVE> private
1542
1550
  join <invitación> [--name <n>] [--kms <config.json>]
@@ -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