@dotrino/vaultd 0.83.0 → 0.84.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/lib/src/env.js CHANGED
@@ -53,11 +53,26 @@ export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,3
53
53
  /** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
54
54
  export function serviceDir (vault, ns) {
55
55
  if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
56
- if (!isValidVaultLabel(vault)) {
57
- throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
58
- }
59
56
  if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
60
- return path.join(serviceRoot(), vault, ns)
57
+ if (isValidVaultLabel(vault)) return path.join(serviceRoot(), vault, ns)
58
+ // SIN `--vault`: SE BUSCA, Y SOLO SE EXIGE SI HAY EMPATE (dueño, 2026-09-01).
59
+ //
60
+ // La etiqueta se hizo obligatoria para que dos bóvedas pudieran tener un cajón que se
61
+ // llame igual sin pisarse. Eso lo arregla la RUTA, no la bandera: con un solo enrolamiento
62
+ // de ese `ns` no hay nada que desambiguar y pedirla es fricción sin nada a cambio.
63
+ //
64
+ // Con dos, en cambio, elegir por el usuario sería adivinar cuál de sus cuentas usar — así
65
+ // que se para y se dice cuáles hay. Eso es lo contrario de un repliegue: no se resuelve
66
+ // una ambigüedad a la brava, se señala.
67
+ const donde = listEnrolled().filter((e) => !e.legacy && e.ns === ns)
68
+ if (donde.length === 1) return path.join(serviceRoot(), donde[0].vault, ns)
69
+ if (donde.length > 1) {
70
+ throw new Error(`"${ns}" is enrolled in more than one vault (${donde.map((e) => e.vault).join(', ')}): pick one with --vault <label>`)
71
+ }
72
+ const otros = listEnrolled().filter((e) => !e.legacy)
73
+ throw new Error(otros.length
74
+ ? `"${ns}" is not enrolled on this machine (there is: ${otros.map((e) => `${e.vault}/${e.ns}`).join(', ')})`
75
+ : `"${ns}" is not enrolled on this machine: run \`dotrino-env enroll --vault <label> --ns ${ns}\` first`)
61
76
  }
62
77
 
63
78
  /** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
@@ -139,18 +154,21 @@ let lastApplied = null
139
154
  * @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
140
155
  * @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
141
156
  * @param {(e:Error, ms:number)=>void} [opts.onRetry]
157
+ * @param {boolean} [opts.publicOnly] Solo las variables PÚBLICAS. No hay claves privadas de
158
+ * por medio, así que la bóveda no pide aprobación: sirve para un arranque que solo
159
+ * necesita configuración (una URL, un puerto) y no debe despertar a nadie.
142
160
  * @returns {Promise<{ns, secrets, injected:string[], overridden:string[], skipped:string[]}>}
143
161
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
144
162
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
145
163
  */
146
- export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
164
+ export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending, publicOnly = false } = {}) {
147
165
  if (override === undefined) override = overrideByDefault()
148
166
  ns = resolveNs(ns)
149
- // La ETIQUETA DE BÓVEDA es obligatoria salvo que se diga el directorio a pelo: es lo que
150
- // hace que dos bóvedas puedan tener un cajón con el mismo nombre sin pisarse.
167
+ // La etiqueta de bóveda es OPCIONAL: `serviceDir` busca el `ns` entre las que hay y solo
168
+ // la exige si está enrolado en más de una (ahí sí hay que elegir, y elige el dueño).
151
169
  dir = dir || serviceDir(resolveVaultLabel(vault), ns)
152
170
  const load = wait ? waitForSecrets : fetchSecrets
153
- const secrets = await load({ dir, ns, onRetry, onPending })
171
+ const secrets = await load({ dir, ns, onRetry, onPending, publicOnly })
154
172
 
155
173
  const missing = required.filter((k) => !(k in secrets))
156
174
  if (missing.length) {
@@ -421,7 +421,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
421
421
  * Renueva el cert automáticamente si está por vencer (best-effort).
422
422
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
423
423
  */
424
- export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
424
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, publicOnly = false } = {}) {
425
425
  let saved = null
426
426
  if (dir) saved = readServiceIdentity(dir)
427
427
  // Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
@@ -499,7 +499,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
499
499
  }
500
500
 
501
501
  const eph = await makeEphemeralKey()
502
- const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now() }
502
+ // `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
503
+ // poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
504
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
503
505
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
504
506
  const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
505
507
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
@@ -1022,11 +1024,11 @@ export function isFinal (e) {
1022
1024
  return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
1023
1025
  }
1024
1026
 
1025
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
1027
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert, publicOnly = false } = {}) {
1026
1028
  let delay = retryMs
1027
1029
  for (;;) {
1028
1030
  try {
1029
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert })
1031
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
1030
1032
  } catch (e) {
1031
1033
  if (isFinal(e)) throw e
1032
1034
  try { onRetry?.(e, delay) } catch (_) {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.83.0",
3
+ "version": "0.84.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": {
@@ -329,10 +329,25 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
329
329
  * Van TODAS sus generaciones, no solo la última, porque desde v5 cada variable puede
330
330
  * venir de una escritura distinta. En v3 devuelve los valores en claro, como siempre.
331
331
  */
332
- bundleFor (ns, devicePub = null) {
332
+ /**
333
+ * @param {string} ns
334
+ * @param {string|null} devicePub
335
+ * @param {{publicOnly?: boolean}} [opts] `publicOnly`: SOLO las públicas, y sin
336
+ * envolturas. Una pública está guardada en claro, así que no hay llave que repartir
337
+ * ni sobre que abrir — y por eso pedirlas no pasa por la aprobación (ver `handleSecrets`).
338
+ */
339
+ bundleFor (ns, devicePub = null, { publicOnly = false } = {}) {
333
340
  assertNs(ns)
334
- const entries = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
341
+ const todo = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
342
+ // EL FILTRO LO HACE LA BÓVEDA, no quien pide. Si mandara todo y el cliente eligiera,
343
+ // pedir «solo públicas» sería la forma de saltarse la aprobación y llevarse las
344
+ // privadas igual — o sea justo lo contrario de lo que se quiere.
345
+ const entries = publicOnly
346
+ ? Object.fromEntries(Object.entries(todo).filter(([, e]) => e.pub))
347
+ : todo
335
348
  if (isLegacy()) return { legacy: true, entries }
349
+ // Sin privadas no hay nada que envolver: el paquete va sin llavero.
350
+ if (publicOnly) return { entries, ns: null, dev: null, wraps: { ns: [], dev: [] } }
336
351
  const misWraps = (bag, k) => (bag[k]?.keyring || [])
337
352
  .filter((g) => g.wraps?.[devicePub])
338
353
  .map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
package/src/vault.js CHANGED
@@ -852,7 +852,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
852
852
  // claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
853
853
  // contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
854
854
  // (`handleApproval`), sellada a la misma `ek`.
855
- if (needsApproval(chk.device, record)) {
855
+ // SOLO PÚBLICAS: NO SE PIDE APROBACIÓN (dueño, 2026-09-01).
856
+ //
857
+ // La aprobación existe para soltar CLAVES PRIVADAS. Una variable pública está guardada
858
+ // en claro —eso es lo que significa marcarla— y ya la ve cualquiera que administre, así
859
+ // que hacer sonar el teléfono del dueño para entregarla es molestarlo por nada.
860
+ //
861
+ // Lo que NO se relaja: quien pide sigue teniendo que ser un miembro identificado, con su
862
+ // papel y con el `cn` de este cajón (las dos comprobaciones de arriba). Y el filtro lo
863
+ // hace la BÓVEDA (`bundleFor({ publicOnly })`), no el que pide: si mandáramos todo y el
864
+ // cliente eligiera, pedir «solo públicas» sería la manera de saltarse la aprobación.
865
+ const publicOnly = p.data?.publicOnly === true
866
+ if (!publicOnly && needsApproval(chk.device, record)) {
856
867
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
857
868
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
858
869
  const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
@@ -866,20 +877,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
866
877
  return
867
878
  }
868
879
  let res
869
- try { res = await resultFor(ns, chk.device, p.data.ek, record) } catch (e) {
880
+ try { res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly }) } catch (e) {
870
881
  return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
871
882
  }
872
- audit('secrets', { device: await deviceIdOf(chk.device), ns })
883
+ audit('secrets', { device: await deviceIdOf(chk.device), ns, ...(publicOnly ? { publicOnly: true } : {}) })
873
884
  reply(from, { type: MSG.SECRETS_RESULT, ...res })
874
885
  }
875
886
 
876
- /** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la maestra. */
877
- async function resultFor (ns, devicePub, ek, record) {
887
+ /** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la llave de sellado. */
888
+ async function resultFor (ns, devicePub, ek, record, { publicOnly = false } = {}) {
878
889
  // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
879
890
  // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
880
891
  // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
881
892
  // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
882
- const b = secrets.bundleFor(ns, devicePub)
893
+ const b = secrets.bundleFor(ns, devicePub, { publicOnly })
883
894
  // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
884
895
  // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
885
896
  // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
@@ -897,7 +908,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
897
908
  // como si se hubiera servido. Desde fuera parecía que el pedido funcionaba y volvía a
898
909
  // aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
899
910
  // bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
900
- if (!b.legacy) {
911
+ if (!b.legacy && !publicOnly) {
901
912
  const faltan = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
902
913
  if (faltan.length) {
903
914
  const quien = await deviceIdOf(devicePub).catch(() => '????-????')