@dotrino/vault 0.47.1 → 0.49.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.
@@ -31,13 +31,16 @@ function help () {
31
31
  console.log(`dotrino-env — credenciales del vault en vez del .env
32
32
 
33
33
  enroll --vault <bóveda> --ns <ns> [--code <invitación>] [--dir <dir>]
34
+ (--vault SOLO hace falta aquí, y para usarlo si el mismo <ns> está en dos bóvedas)
34
35
  Registra ESTE servicio contra el vault (una sola vez).
35
36
  Antes, en el vault: dotrino-vault pair --service <ns>
36
37
  Si no pasas --code (alias --qr), se pide por consola (también acepta stdin).
37
38
 
38
39
  status servicios enrolados en esta máquina
39
40
  check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
40
- run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]
41
+ run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]
42
+ --public trae SOLO las variables públicas. No pide aprobación en el teléfono:
43
+ la aprobación es para las claves privadas, y una pública está en claro.
41
44
  ssh-agent [--ns <ns>] [--socket <ruta>]
42
45
  agente SSH con las llaves (SSH_KEY_* del cajón) solo en memoria;
43
46
  pide el cajón al arrancar. Pon SSH_AUTH_SOCK en la ruta que imprime.
@@ -82,9 +85,15 @@ async function readInvite () {
82
85
  * `--vault` y no `--profile`: `dotrino-vault --profile` ya significa otra cosa y los dos
83
86
  * comandos se usan seguidos.
84
87
  */
85
- function etiquetaBoveda () {
88
+ /**
89
+ * La etiqueta de bóveda, si la hay. AL ENROLAR es obligatoria (`exigida: true`): ahí se está
90
+ * creando el directorio y hay que decir de quién es. Para USARLO no: el `ns` se busca entre
91
+ * las bóvedas enroladas y solo hace falta elegir si está en más de una (dueño, 2026-09-01).
92
+ */
93
+ function etiquetaBoveda ({ exigida = false } = {}) {
86
94
  const v = resolveVaultLabel(flag('vault'))
87
95
  if (!v) {
96
+ if (!exigida) return null
88
97
  console.error('falta --vault <nombre> (o DOTRINO_ENV_VAULT)')
89
98
  console.error('')
90
99
  console.error(' Dice DE QUÉ BÓVEDA es este cajón. Es una etiqueta tuya, de esta máquina:')
@@ -99,8 +108,8 @@ function etiquetaBoveda () {
99
108
  async function cmdEnroll () {
100
109
  const ns = flag('ns')
101
110
  if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
102
- const perfil = etiquetaBoveda()
103
- const dir = flag('dir') || serviceDir(bov, ns)
111
+ const boveda = etiquetaBoveda({ exigida: true })
112
+ const dir = flag('dir') || serviceDir(boveda, ns)
104
113
  const qr = parseInvite((flag('qr') || flag('code')) || await readInvite())
105
114
 
106
115
  console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
@@ -184,7 +193,7 @@ async function cmdCheck () {
184
193
  async function cmdRun () {
185
194
  const sep = argv.indexOf('--')
186
195
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
187
- if (!cmd.length) { console.error('uso: dotrino-env run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
196
+ if (!cmd.length) { console.error('uso: dotrino-env run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]'); process.exit(2) }
188
197
  const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
189
198
  // POR QUÉ SE REINTENTA, SIEMPRE. Sin esto la espera es muda: la bóveda contestaba, la
190
199
  // respuesta se caía por un motivo concreto —un aparato sin sobre en su cajón, por
@@ -192,7 +201,12 @@ async function cmdRun () {
192
201
  // dueño hubiera aprobado cada una. Un reintento que no dice su causa es un fallback: le
193
202
  // pone cara de espera a un error. (Ver CLAUDE.md, «nada de repliegues».)
194
203
  const onRetry = (e, ms) => console.error('[dotrino-env] failed: %s — retrying in %ds', e.message, Math.round(ms / 1000))
195
- const { injected, overridden } = await loadEnv({ ns: flag('ns'), vault: etiquetaBoveda(), onPending, onRetry })
204
+ // `--public`: SOLO las variables públicas, y por eso no hace sonar el teléfono de nadie.
205
+ // La aprobación existe para soltar claves privadas; una pública está guardada en claro y
206
+ // ya la ve quien administra. Es lo que quiere un arranque que solo necesita configuración
207
+ // —una URL, un puerto— y no tiene por qué despertar al dueño.
208
+ const publicOnly = argv.includes('--public')
209
+ const { injected, overridden } = await loadEnv({ ns: flag('ns'), vault: etiquetaBoveda(), onPending, onRetry, publicOnly })
196
210
  console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
197
211
  overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
198
212
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.47.1",
3
+ "version": "0.49.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/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) {
package/src/service.js CHANGED
@@ -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 })
@@ -666,7 +668,17 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
666
668
  const gen = e.gen ?? e.e?.gen ?? 0
667
669
  await comprobarFirma(e.owner, key, gen, e.e, e.seal)
668
670
  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`)
671
+ if (!cek) {
672
+ // NO ES UN TROPIEZO: ES UN ESTADO QUE HAY QUE ARREGLAR EN LA BÓVEDA.
673
+ //
674
+ // Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
675
+ // NO PUEDE tenerla: envolver exige abrir la llave del cajón, y eso solo pasa cuando
676
+ // el dueño abre la bóveda. Reintentar no la crea. Lleva `code` porque el mensaje lo
677
+ // empareja otra capa y una frase no es un contrato (ver `dotrino-error-strings`).
678
+ throw Object.assign(
679
+ 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`),
680
+ { code: 'no-wrapping' })
681
+ }
670
682
  out[key] = await decryptWithCek({ cek, envelope: e.e })
671
683
  }
672
684
  return out
@@ -991,16 +1003,34 @@ function fingerprintOf (secrets) {
991
1003
  * servicio no opera hasta que esto resuelva — esa es la regla.
992
1004
  * @returns {Promise<Record<string,string>>}
993
1005
  */
994
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
1006
+ /**
1007
+ * ¿ESTE FALLO SE ARREGLA SOLO ESPERANDO? Si no, reintentar es peor que rendirse.
1008
+ *
1009
+ * Lo NO transitorio exige que alguien haga algo: falta de enrolamiento, cert revocado o
1010
+ * vencido, scope equivocado → re-emparejar. Que te lo DENIEGUEN tampoco se reintenta: fue
1011
+ * una decisión, no un tropiezo.
1012
+ *
1013
+ * Y no tener ENVOLTURA en el cajón (`no-wrapping`) es el que costó una tarde. La bóveda
1014
+ * pedía aprobación, el teléfono timbraba, el dueño aprobaba, la bóveda contestaba un bundle
1015
+ * que este aparato no podía abrir, y el agente volvía a pedir — así que el teléfono
1016
+ * timbraba otra vez. «Sigo aprobando y aprobando», textual. Ninguna aprobación iba a crear
1017
+ * esa envoltura: envolver exige abrir la llave del cajón, o sea ABRIR la bóveda.
1018
+ *
1019
+ * Se decide por `e.code` donde lo hay: emparejar un mensaje por su texto se rompe en
1020
+ * silencio en cuanto alguien lo reescribe (ver `dotrino-error-strings`).
1021
+ */
1022
+ export function isFinal (e) {
1023
+ if (e?.code === 'no-wrapping') return true
1024
+ return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
1025
+ }
1026
+
1027
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert, publicOnly = false } = {}) {
995
1028
  let delay = retryMs
996
1029
  for (;;) {
997
1030
  try {
998
- 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 })
999
1032
  } 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
1033
+ if (isFinal(e)) throw e
1004
1034
  try { onRetry?.(e, delay) } catch (_) {}
1005
1035
  await new Promise((r) => setTimeout(r, delay))
1006
1036
  delay = Math.min(maxRetryMs, Math.round(delay * 1.6))