@dotrino/vault 0.15.0 → 0.16.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/README.md CHANGED
@@ -91,6 +91,25 @@ que los secretos estén. Si el vault no está disponible, **espera** (reintento
91
91
  backoff) — un servicio sin vault no arranca, no opera con secretos viejos ni vacíos.
92
92
  Un fallo NO transitorio (sin enrolar, cert revocado, scope equivocado) sí aborta.
93
93
 
94
+ #### Esperar al vault es la REGLA. La excepción es una sola
95
+
96
+ Un agente enrolado **espera**. No es una preferencia: arrancar igual significaría
97
+ operar con la configuración vieja del `.env`, que es justo lo que el vault vino a
98
+ dejar de ser. Y la espera casi nunca duele, porque estos agentes no son críticos:
99
+ que un bot o un firmador tarden en levantar no rompe a nadie.
100
+
101
+ La **única** excepción conocida es **el proxio**, y no por importancia sino por una
102
+ razón estructural: el vault habla con sus servicios **por el proxio**. Un proxio que
103
+ espera al vault espera a alguien que necesita que el proxio ya esté escuchando —
104
+ abrazo mortal, y con él se cae el vault de todo el mundo. Por eso el proxio arranca
105
+ con lo que tenga y aplica la configuración cuando llega, con `applyEnv`.
106
+
107
+ > `applyEnv` existe **para ese caso**, no como alternativa cómoda al bloqueo. Si tu
108
+ > agente no está en el camino por el que viaja el propio vault, usa
109
+ > `import '@dotrino/vault/config'` y deja que espere. El precio de la excepción es
110
+ > real: lo que sólo se lee al arrancar llega tarde y no toma efecto hasta reiniciar,
111
+ > así que hay que avisarlo en el log — el proxio lo hace.
112
+
94
113
  Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
95
114
 
96
115
  ```bash
@@ -120,6 +139,48 @@ suya hacia arriba. De ahí tres reglas, que el paquete aplica solas:
120
139
  > instancias y citas vivas dejan de resolver y los peers que lo tenían pineado lo
121
140
  > rechazan hasta re-pinearlo. Es a propósito: así se echa a un nodo comprometido.
122
141
 
142
+ ### Rotar: la bóveda avisa y el agente SE REINICIA
143
+
144
+ Cambiar un secreto en la bóveda no sirve de nada si quien lo usa no se entera. Al
145
+ guardar, la bóveda manda un **aviso firmado** a los agentes de ese `ns` (sin
146
+ valores: sólo dice que cambió), **agrupando** las escrituras seguidas para que
147
+ cargar cinco valores no provoque cinco reinicios.
148
+
149
+ El agente **no recarga en caliente: termina**, y lo levanta su supervisor (pm2,
150
+ systemd `Restart=always`). Con `import '@dotrino/vault/config'` ya viene puesto.
151
+
152
+ Salir en vez de recargar, por tres razones — y la primera es la de peso:
153
+
154
+ 1. **Borra de memoria el valor viejo.** En JavaScript un secreto no se puede
155
+ borrar: los strings son inmutables, no hay `zeroize`, y el valor sigue en el
156
+ heap hasta que al recolector le apetezca, más lo que capturó cada *closure* y
157
+ cada caché derivada. Una llave se rota casi siempre **porque se filtró**, así
158
+ que dejarla viva en el proceso anula el motivo de rotarla. Un proceso nuevo
159
+ empieza con el heap limpio.
160
+ 2. **Lee todo fresco.** Recargar en caliente exige que cada sitio que leyó una
161
+ variable sepa releerla; esa lista hay que mantenerla para siempre y, cuando se
162
+ queda corta, falla en silencio.
163
+ 3. **Es un interruptor de emergencia.** Revocar el cert de un agente ya no espera a
164
+ que alguien se acuerde de reiniciarlo: recibe el `REVOKED` firmado, se apaga, y
165
+ al arrancar `fetchSecrets` recibe «no autorizado: revoked», que no se arregla
166
+ reintentando. Antes, revocar no le quitaba nada a un proceso ya corriendo.
167
+
168
+ Defensas, porque una señal que provoca reinicios es un arma si se descuida: firma
169
+ de la maestra pineada, `ns` que coincida, frescura y anti-replay, **gracia de
170
+ arranque** y **piso entre avisos** (si la configuración nueva rompe el arranque, sin
171
+ eso el servicio entra en ciclo) y **jitter** (diez agentes del mismo `ns` no salen
172
+ todos en el mismo segundo).
173
+
174
+ ```js
175
+ import { watchEnv } from '@dotrino/vault/env'
176
+ await watchEnv({ ns: 'miapp' }) // termina el proceso al cambiar
177
+ await watchEnv({ ns: 'proxy', onUpdate: (i) => … }) // o decide tú (ver abajo)
178
+ ```
179
+
180
+ `onUpdate` es para cuando terminar **no es una opción**. El caso real es el proxio:
181
+ reiniciarlo corta el transporte de todo el ecosistema, así que anota el aviso, lo
182
+ publica en su `GET /peers` y deja el momento a un humano.
183
+
123
184
  ### Precedencia: el vault MANDA
124
185
 
125
186
  Los valores del vault **pisan** los del `.env` y los del entorno. El vault no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.15.0",
3
+ "version": "0.16.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/config.js CHANGED
@@ -11,13 +11,21 @@
11
11
  * EL VAULT MANDA: lo que venga de aquí PISA lo que ya hubiera en el entorno
12
12
  * (incluido un `.env` cargado antes). Ver `env.js` para el porqué.
13
13
  *
14
+ * Y queda A LA ESCUCHA: cuando el dueño cambia la configuración en la bóveda, el
15
+ * proceso TERMINA para que su supervisor lo levante limpio. No se recarga en
16
+ * caliente a propósito — salir borra de la memoria el valor viejo, que en
17
+ * JavaScript no hay forma de borrar de otra manera, y una llave se rota casi
18
+ * siempre porque se filtró. Requiere correr bajo pm2 o systemd `Restart=always`.
19
+ * Se apaga con `DOTRINO_ENV_WATCH=0`.
20
+ *
14
21
  * Config por entorno:
15
22
  * DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
16
23
  * DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
17
24
  * DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
18
25
  * DOTRINO_ENV_OVERRIDE '0' para NO pisar el entorno en esta corrida (depuración)
26
+ * DOTRINO_ENV_WATCH '0' para no escuchar avisos de cambio (no terminar solo)
19
27
  */
20
- import { loadEnv } from './env.js'
28
+ import { loadEnv, watchEnv } from './env.js'
21
29
 
22
30
  const quiet = process.env.DOTRINO_ENV_QUIET === '1'
23
31
 
@@ -36,3 +44,15 @@ if (!quiet) {
36
44
  console.error('[dotrino-env] pisaron un valor previo del entorno: %s', overridden.join(', '))
37
45
  }
38
46
  }
47
+
48
+ // La escucha no debe impedir que el proceso termine por su cuenta si no tiene
49
+ // nada más que hacer, así que si falla (agente sin enrolar del todo, proxio
50
+ // caído) se avisa y se sigue: el servicio ya tiene su configuración; lo único
51
+ // que pierde es enterarse del próximo cambio.
52
+ if (process.env.DOTRINO_ENV_WATCH !== '0') {
53
+ try {
54
+ await watchEnv({ ns, quiet })
55
+ } catch (e) {
56
+ if (!quiet) console.error('[dotrino-env] sin escucha de cambios (%s): habrá que reiniciar a mano al rotar', e.message)
57
+ }
58
+ }
package/src/env.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import fs from 'node:fs'
22
22
  import os from 'node:os'
23
23
  import path from 'node:path'
24
- import { fetchSecrets, waitForSecrets, readServiceIdentity } from './service.js'
24
+ import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges } from './service.js'
25
25
  import { isValidSecretsNs } from './protocol.js'
26
26
 
27
27
  /** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
@@ -113,10 +113,19 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
113
113
  /**
114
114
  * Vuelca un bundle de secretos en `process.env` y cuenta qué cambió.
115
115
  *
116
- * Separado de `loadEnv` porque un servicio que NO puede bloquear su arranque
117
- * esperando al vault (el proxy: el vault le habla POR el proxy, así que
118
- * esperarlo sería un abrazo mortal) igual necesita aplicar el bundle cuando
119
- * llegue, tarde y por su cuenta.
116
+ * ESTO NO ES LA FORMA NORMAL. Un agente enrolado ESPERA al vault
117
+ * (`loadEnv` / `import '@dotrino/vault/config'`): arrancar igual sería operar con
118
+ * la configuración vieja del `.env`, que es justo lo que el vault vino a dejar de
119
+ * ser, y la espera casi nunca duele porque estos agentes no son críticos.
120
+ *
121
+ * `applyEnv` existe para la ÚNICA excepción estructural: **el proxio**. El vault
122
+ * habla con sus servicios POR el proxio, así que un proxio que espera al vault
123
+ * espera a alguien que necesita el proxio escuchando — abrazo mortal, y con él se
124
+ * cae el vault de todos. Ese caso arranca con lo que tenga y aplica el bundle
125
+ * cuando llega, tarde y por su cuenta.
126
+ *
127
+ * Si tu agente no está en el camino por el que viaja el propio vault, no uses
128
+ * esto: usa `loadEnv` y deja que espere.
120
129
  *
121
130
  * @returns {{injected:string[], overridden:string[], skipped:string[]}}
122
131
  */
@@ -135,3 +144,63 @@ export function applyEnv (secrets, override = overrideByDefault()) {
135
144
  }
136
145
  return { injected, overridden, skipped }
137
146
  }
147
+
148
+ /**
149
+ * Queda a la escucha de la bóveda y, cuando avisa que la configuración cambió,
150
+ * TERMINA EL PROCESO para que el supervisor lo levante con todo fresco.
151
+ *
152
+ * Por qué salir en vez de recargar en caliente:
153
+ *
154
+ * 1. **Borra de memoria el valor viejo.** Es la razón de peso. En JavaScript un
155
+ * secreto no se puede borrar: los strings son inmutables, no hay `zeroize`, y
156
+ * el valor sigue en el heap hasta que al recolector le apetezca — más lo que
157
+ * capturó cada closure y cada caché derivada. Y una llave se rota casi siempre
158
+ * PORQUE SE FILTRÓ, así que dejarla viva en el proceso anula el motivo de
159
+ * rotarla. Un proceso nuevo empieza con el heap limpio.
160
+ * 2. **Lee todo fresco.** Recargar en caliente exige que cada sitio que leyó una
161
+ * variable sepa releerla; esa lista hay que mantenerla para siempre y cuando se
162
+ * queda corta falla en silencio.
163
+ * 3. **Sirve de interruptor de emergencia.** Revocar el cert de un agente ya no
164
+ * espera a que alguien se acuerde de reiniciarlo: se apaga, y al arrancar
165
+ * `fetchSecrets` recibe «no autorizado: revoked» y no vuelve.
166
+ *
167
+ * NO se reinicia solo: sale, y lo levanta quien lo supervisa (pm2, systemd con
168
+ * `Restart=always`). Un proceso no puede reiniciarse a sí mismo de forma fiable, y
169
+ * el supervisor ya trae backoff y tope de intentos, que es justo lo que evita que
170
+ * una configuración rota se convierta en un ciclo.
171
+ *
172
+ * @param {Object} [opts]
173
+ * @param {string} [opts.ns]
174
+ * @param {string} [opts.dir]
175
+ * @param {(info:{ns:string, ts:number, motivo:'cambio'|'revocado'})=>void} [opts.onUpdate]
176
+ * Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
177
+ * opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
178
+ * @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
179
+ * no levantaría un servicio que sale con 0, pero `Restart=always` sí, y pm2
180
+ * también. Se elige 0 porque salir a propósito no es un fallo.
181
+ * @returns {Promise<{stop:()=>void}>}
182
+ */
183
+ export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...resto } = {}) {
184
+ ns = resolveNs(ns)
185
+ dir = dir || serviceDir(ns)
186
+ const decir = (m) => { if (!quiet) console.error(m) }
187
+
188
+ const salir = (motivo) => {
189
+ decir(`[dotrino-env] ${motivo === 'revocado'
190
+ ? 'la bóveda REVOCÓ este agente: terminando (no volverá a arrancar)'
191
+ : 'configuración nueva en la bóveda: terminando para que el supervisor lo levante limpio'}`)
192
+ process.exit(motivo === 'revocado' ? 1 : exitCode)
193
+ }
194
+
195
+ return watchSecretsChanges({
196
+ dir,
197
+ ns,
198
+ log: decir,
199
+ onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, motivo: 'cambio' }) : salir('cambio')),
200
+ // Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
201
+ // levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
202
+ // reinicios fallidos es lo que hace que se note en vez de girar en silencio.
203
+ onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), motivo: 'revocado' }) : salir('revocado')),
204
+ ...resto
205
+ })
206
+ }
package/src/protocol.js CHANGED
@@ -45,6 +45,20 @@ export const MSG = Object.freeze({
45
45
  RENEWED: 'vault.renewed', // vault → dispositivo: { cert } (cert fresco, misma sub-clave/scope)
46
46
  SECRETS: 'vault.secrets', // servicio → vault: { data:{op,ns,ek,publickey,ts}, signature, cert }
47
47
  SECRETS_RESULT: 'vault.secrets.result', // vault → servicio: { body:{op,ns,enc,ts}, signature } (enc SELLADO a ek; body firmado por la maestra)
48
+ // AVISO DE CAMBIO (no lleva valores): la bóveda dice «la configuración del ns
49
+ // cambió». El agente no la recarga en caliente — SALE limpio y su supervisor lo
50
+ // levanta. Dos razones, y la segunda es la de peso:
51
+ // · Lee todo fresco. Recargar en caliente exige que cada sitio que leyó una
52
+ // variable sepa releerla, y esa lista hay que mantenerla para siempre.
53
+ // · BORRA DE MEMORIA EL VALOR VIEJO. En JavaScript un secreto no se puede
54
+ // borrar: los strings son inmutables, no hay zeroize, y el valor queda en el
55
+ // heap hasta que al recolector le apetezca — más lo que capturó cada closure
56
+ // y cada caché derivada. Una llave se rota casi siempre PORQUE SE FILTRÓ, así
57
+ // que dejarla viva en el proceso anula la razón de rotarla. Un proceso nuevo
58
+ // empieza con el heap limpio.
59
+ // Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
60
+ // aviso de reinicio sin autenticar ES un ataque de denegación.
61
+ SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
48
62
  ERROR: 'vault.error' // vault → dispositivo: { error }
49
63
  })
50
64
 
package/src/service.js CHANGED
@@ -365,6 +365,143 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
365
365
  } finally { client.close() }
366
366
  }
367
367
 
368
+ /**
369
+ * Escucha los avisos de cambio de configuración de la bóveda.
370
+ *
371
+ * A diferencia de `fetchSecrets` —que abre, pide y cierra—, esto mantiene la
372
+ * conexión ABIERTA e identificada con la llave del servicio: es la dirección a la
373
+ * que la bóveda le habla. Por eso un agente que quiera enterarse de una rotación
374
+ * deja de ser un cliente de paso y pasa a ser uno permanente.
375
+ *
376
+ * Lo que NO hace: recargar nada. El aviso no trae valores, y la reacción correcta
377
+ * es que el proceso termine y lo levante su supervisor (ver `watchEnv`).
378
+ *
379
+ * Defensas, porque una señal que provoca reinicios es un arma si se descuida:
380
+ * · **Firma de la maestra pineada** y `ns` que coincida. Sin esto, cualquiera
381
+ * reinicia la flota ajena cuando quiera.
382
+ * · **Frescura y anti-replay**: `ts` dentro de la ventana y estrictamente mayor
383
+ * que el último obedecido. Un aviso viejo reproducido no vuelve a disparar.
384
+ * · **Gracia de arranque y piso entre avisos**: no se obedece recién arrancado ni
385
+ * dos veces seguidas. Si la configuración nueva rompe el arranque, sin esto el
386
+ * servicio entra en un ciclo de reinicios.
387
+ * · **Jitter**: diez agentes del mismo ns no pueden salir todos en el mismo
388
+ * segundo.
389
+ *
390
+ * @param {Object} opts
391
+ * @param {string} opts.dir Identidad del servicio (`service-identity.json`).
392
+ * @param {string} [opts.ns]
393
+ * @param {(info:{ns:string, ts:number})=>void} opts.onChange
394
+ * @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
395
+ * @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
396
+ * @param {number} [opts.minIntervalMs=60000] Mínimo entre dos avisos obedecidos.
397
+ * @param {number} [opts.jitterMs=5000] Espera aleatoria antes de avisar.
398
+ * @param {(m:string)=>void} [opts.log]
399
+ * @returns {Promise<{stop:()=>void}>}
400
+ */
401
+ export async function watchSecretsChanges ({
402
+ dir, ns, onChange, onRevoked, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000, log = () => {}
403
+ } = {}) {
404
+ const saved = dir ? readServiceIdentity(dir) : null
405
+ ns = ns || saved?.ns
406
+ if (!saved?.device || !saved?.cert || !saved?.iss || !saved?.proxy) {
407
+ throw new Error('servicio sin enrolar: no hay a quién escuchar')
408
+ }
409
+ const master = saved.iss
410
+ const nacido = Date.now()
411
+ let ultimoTs = 0
412
+ let ultimoObedecido = 0
413
+ const enVuelo = new Set() // avisos cuya firma se está comprobando ahora mismo
414
+ let parado = false
415
+ let client = null
416
+ let reintento = null
417
+
418
+ /**
419
+ * REVOCACIÓN = interruptor de emergencia. Hasta ahora revocar un cert no le
420
+ * quitaba nada a un servicio YA CORRIENDO: seguía operando con los secretos en
421
+ * memoria hasta que alguien se acordara de reiniciarlo (el README decía lo
422
+ * contrario). Teniendo la conexión abierta, el aviso llega y el agente se apaga
423
+ * en el acto — y no vuelve, porque al arrancar `fetchSecrets` recibe
424
+ * «no autorizado: revoked», que no se arregla reintentando.
425
+ *
426
+ * Sin gracia, sin piso y sin jitter, al revés que un cambio de configuración:
427
+ * apagar algo comprometido es justo lo que no debe esperar su turno.
428
+ */
429
+ const atenderRevocacion = async (payload) => {
430
+ const body = payload.body
431
+ if (!body || body.op !== 'revoke') return
432
+ // Que sea MI revocación y no la de otro dispositivo del mismo dueño.
433
+ if (body.sub !== saved.device.publickey) return
434
+ if (saved.cert?.nonce && body.nonce !== saved.cert.nonce) return
435
+ if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
436
+ return log('[vault] aviso de revocación MAL FIRMADO: ignorado')
437
+ }
438
+ log('[vault] ⚠ la bóveda REVOCÓ el cert de este agente: apagando')
439
+ try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
440
+ }
441
+
442
+ const atender = async (payload) => {
443
+ if (payload?.type === MSG.REVOKED) return atenderRevocacion(payload)
444
+ if (payload?.type !== MSG.SECRETS_CHANGED) return
445
+ const body = payload.body
446
+ if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
447
+ if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) {
448
+ return log('[vault] aviso de cambio con fecha fuera de ventana: ignorado')
449
+ }
450
+ if (body.ts <= ultimoTs) return log('[vault] aviso de cambio repetido: ignorado')
451
+ // Dos copias del MISMO aviso pueden llegar a la vez, y comprobar la firma es
452
+ // asíncrono: sin esta marca las dos pasarían el corte de `ultimoTs` antes de
453
+ // que ninguna lo actualizara, y el agente se reiniciaría por partida doble.
454
+ // La marca se pone antes del `await` y el `ultimoTs` DESPUÉS de verificar, para
455
+ // que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
456
+ if (enVuelo.has(body.ts)) return
457
+ enVuelo.add(body.ts)
458
+ let valida = false
459
+ try {
460
+ valida = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
461
+ } finally { enVuelo.delete(body.ts) }
462
+ if (!valida) return log('[vault] aviso de cambio MAL FIRMADO: ignorado (no viene de tu bóveda)')
463
+ if (body.ts <= ultimoTs) return
464
+ ultimoTs = body.ts
465
+
466
+ const ahora = Date.now()
467
+ if (ahora - nacido < graceMs) {
468
+ return log('[vault] aviso de cambio recién arrancado: ignorado (evita el ciclo de reinicios)')
469
+ }
470
+ if (ahora - ultimoObedecido < minIntervalMs) {
471
+ return log('[vault] aviso de cambio demasiado seguido del anterior: ignorado')
472
+ }
473
+ ultimoObedecido = ahora
474
+
475
+ const espera = Math.floor(Math.random() * jitterMs)
476
+ log(`[vault] la bóveda avisa que la configuración de «${ns}» cambió (en ${espera} ms)`)
477
+ setTimeout(() => { if (!parado) { try { onChange?.({ ns, ts: body.ts }) } catch (e) { log('[vault] ' + e.message) } } }, espera)
478
+ }
479
+
480
+ const conectar = async () => {
481
+ if (parado) return
482
+ try {
483
+ client = await freshClient(saved.proxy)
484
+ await identifyAsService(client, saved.device)
485
+ client.on('message', (_from, p) => { atender(p).catch(() => {}) })
486
+ // Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
487
+ // eso es exactamente el momento en que uno querría enterarse de una rotación.
488
+ client.on('disconnected', () => { if (!parado) reintento = setTimeout(conectar, 5000) })
489
+ log('[vault] a la escucha de cambios de configuración')
490
+ } catch (e) {
491
+ if (!parado) reintento = setTimeout(conectar, 5000)
492
+ }
493
+ }
494
+ await conectar()
495
+
496
+ return {
497
+ stop () {
498
+ parado = true
499
+ clearTimeout(reintento)
500
+ try { client?.close() } catch (_) {}
501
+ }
502
+ }
503
+ }
504
+
368
505
  /**
369
506
  * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
370
507
  * disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El