@dotrino/vault 0.75.0 → 0.77.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.
@@ -16,7 +16,8 @@ import fs from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import readline from 'node:readline/promises'
18
18
  import { spawn } from 'node:child_process'
19
- import { enrollService, readServiceIdentity, fetchSecrets } from '../src/service.js'
19
+ import { enrollService, readServiceIdentity, fetchSecrets, storeVars } from '../src/service.js'
20
+ import { parseEnvText, parseEnvInput } from '../src/envtext.js'
20
21
  import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs, resolveVaultLabel, isValidVaultLabel } from '../src/env.js'
21
22
  // Se usaba sin importarlo: `enroll` —el comando principal— moría con
22
23
  // «sharedParseInvite is not defined» en cuanto tocaba una invitación. Nunca
@@ -88,6 +89,16 @@ function help () {
88
89
  qué aparato es cada servicio de esta máquina: su ID (el de
89
90
  «dotrino-vault members»), su bóveda, sus permisos. Sin red.
90
91
  status servicios enrolados en esta máquina
92
+ import [<archivo>] [--ns <ns>] [--vault <bóveda>] [--public K1,K2 | --all-public]
93
+ [--link <dir>]
94
+ sube un .env (por defecto ./.env) al cajón de ESTE servicio. Se
95
+ sella aquí; la bóveda no ve los valores. Si hay quien apruebe, se
96
+ pide aprobación y al aprobar también puede cambiar las que ya
97
+ existen; si nadie puede aprobar, solo añade las que faltan.
98
+ Borrar, nunca: eso se hace en la bóveda. --link usa el enlace de
99
+ un agente (link.json) en vez de la identidad de dotrino-env.
100
+ set K=v [K2=v2 …] [--ns <ns>] [--public K1,K2 | --all-public] [--link <dir>]
101
+ lo mismo, con los valores en la orden
91
102
  check [--ns <ns>] [--public]
92
103
  pide los secretos al vault y lista sus NOMBRES (nunca los valores)
93
104
  con --public no pide aprobación: solo mira las públicas
@@ -283,6 +294,80 @@ async function cmdInfo () {
283
294
  console.log(infos.map(formatDeviceInfo).join('\n\n'))
284
295
  }
285
296
 
297
+ /**
298
+ * `import` y `set`: SUBIR VARIABLES AL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para
299
+ * facilitar la migración de ENVs a Dotrino»). El `.env` de la máquina sube tal cual; qué
300
+ * se guarda y cuándo lo decide la bóveda (`storeVars`): con aprobación puede editar, sin
301
+ * ella solo añade, y borrar no existe por aquí.
302
+ */
303
+ async function cmdStore (fromFile) {
304
+ let parsed
305
+ if (fromFile) {
306
+ const file = argv[1] && !argv[1].startsWith('--') ? argv[1] : '.env'
307
+ let text
308
+ try { text = fs.readFileSync(file, 'utf8') } catch (e) { console.error('no puedo leer %s: %s', file, e.message); process.exit(1) }
309
+ parsed = parseEnvText(text)
310
+ } else {
311
+ const pares = []
312
+ for (let i = 1; i < argv.length; i++) {
313
+ if (argv[i].startsWith('--')) { if (!['--all-public'].includes(argv[i])) i++; continue }
314
+ pares.push(argv[i])
315
+ }
316
+ parsed = parseEnvInput(pares.join(' '))
317
+ }
318
+ // UN ERROR PARA TODO: una línea mal escrita no se sube a medias, se dice y se para.
319
+ if (parsed.errors.length) {
320
+ for (const e of parsed.errors) {
321
+ console.error(e.code === 'dup' ? ` línea ${e.line}: ${e.key} repetida (ya estaba en la línea ${e.first})`
322
+ : e.code === 'novalue' ? ` línea ${e.line}: ${e.key} sin valor`
323
+ : e.code === 'key' ? ` línea ${e.line}: «${e.key}» no es un nombre de variable válido`
324
+ : e.code === 'empty' ? ' no hay ninguna variable'
325
+ : ` línea ${e.line}: no tiene la forma CLAVE=valor`)
326
+ }
327
+ console.error('No se subió nada. Corrige lo de arriba y vuelve a intentarlo.')
328
+ process.exit(1)
329
+ }
330
+ const allPublic = argv.includes('--all-public')
331
+ const publicas = new Set(String(flag('public') || '').split(',').map((k) => k.trim()).filter(Boolean))
332
+ const desconocidas = [...publicas].filter((k) => !parsed.items.some((it) => it.key === k))
333
+ if (desconocidas.length) { console.error('--public nombra variables que no se van a subir: %s', desconocidas.join(', ')); process.exit(2) }
334
+ const vars = parsed.items.map((it) => ({ key: it.key, value: it.value, public: allPublic || publicas.has(it.key) }))
335
+
336
+ // DE QUIÉN: la identidad de dotrino-env o, con --link, el enlace de un agente (terminal,
337
+ // ia, content…), que es un aparato con cajón igual que cualquier servicio.
338
+ let who
339
+ const linkDir = flag('link')
340
+ if (linkDir) {
341
+ let link
342
+ try { link = JSON.parse(fs.readFileSync(path.join(linkDir, 'link.json'), 'utf8')) } catch (e) { console.error('no puedo leer %s/link.json: %s', linkDir, e.message); process.exit(1) }
343
+ const ns = flag('ns') || link.ns
344
+ if (!ns) { console.error('ese enlace no dice su cajón: pásalo con --ns <ns>'); process.exit(2) }
345
+ who = { ns, proxyUrl: link.proxy, masterPubkey: link.iss, device: link.device, cert: link.cert, dondeDice: `${linkDir} (cajón ${ns})` }
346
+ } else {
347
+ const ns = resolveNs(flag('ns'))
348
+ const dir = serviceDir(etiquetaBoveda(), ns)
349
+ who = { dir, ns, dondeDice: `${path.basename(path.dirname(dir))}/${ns}` }
350
+ }
351
+
352
+ const pub = vars.filter((v) => v.public).map((v) => v.key)
353
+ console.log('Subiendo %d variable(s) a %s: %s', vars.length, who.dondeDice, vars.map((v) => v.key).join(', '))
354
+ if (pub.length) console.log(' públicas: %s', pub.join(', '))
355
+ const { dondeDice, ...ident } = who
356
+ const r = await storeVars({ ...ident, vars }).catch((e) => {
357
+ if (e.code === 'exists') {
358
+ console.error('\n%s\nSin nadie que pueda aprobar, desde aquí solo se añaden variables nuevas. Para cambiar una que ya existe, hazlo en la bóveda (dotrino-vault secret set) o dale a alguien el permiso de aprobar.', e.message)
359
+ } else console.error('\nno se pudo subir: %s', e.message)
360
+ process.exit(1)
361
+ })
362
+ if (r.pending) {
363
+ console.log('\nPedido de aprobación enviado (%s). Apruébalo en un aparato con «aprueba»:', r.pending)
364
+ console.log('se guardarán al aprobar. Con la aprobación también se pueden cambiar las que ya existían.')
365
+ } else {
366
+ console.log('\nGuardadas: %s', r.keys.join(', '))
367
+ console.log('(nadie en tu cuenta puede aprobar, así que solo se añaden las que faltaban)')
368
+ }
369
+ }
370
+
286
371
  async function cmdCheck () {
287
372
  const ns = resolveNs(flag('ns'))
288
373
  // `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
@@ -377,6 +462,8 @@ const run = async () => {
377
462
  case 'enroll': return cmdEnroll()
378
463
  case 'status': return cmdStatus()
379
464
  case 'info': return cmdInfo()
465
+ case 'import': return cmdStore(true)
466
+ case 'set': return cmdStore(false)
380
467
  case 'check': return cmdCheck()
381
468
  case 'run': return cmdRun()
382
469
  case 'ssh-agent': return cmdSshAgent()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.75.0",
3
+ "version": "0.77.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
@@ -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, watchSecretsChanges } from './service.js'
24
+ import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges, lastDigestOf } 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. */
@@ -276,10 +276,14 @@ export async function watchEnv ({ ns, vault, dir, applied = lastApplied ?? undef
276
276
  process.exit(reason === 'revoked' ? 1 : exitCode)
277
277
  }
278
278
 
279
+ // La HUELLA de lo que este proceso puso a correr (la trajo el cajón al pedirlo). Con ella la
280
+ // primera comparación ya detecta un cambio ocurrido entre pedir y escuchar, sin volver a
281
+ // pedir las claves — que a un servicio con aprobación le hacía sonar el teléfono al dueño.
282
+ const pub = readServiceIdentity(dir)?.device?.publickey
279
283
  return watchSecretsChanges({
280
284
  dir,
281
285
  ns,
282
- applied,
286
+ digest: applied !== undefined && pub ? lastDigestOf(ns, pub) : null,
283
287
  log: say,
284
288
  onChange: ({ ts, via }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed', via }) : exitNow('changed')),
285
289
  // Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
package/src/service.js CHANGED
@@ -512,6 +512,114 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
512
512
  } finally { client.close() }
513
513
  }
514
514
 
515
+ /**
516
+ * GUARDA VARIABLES EN EL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para facilitar la
517
+ * migración de ENVs a Dotrino»). Es lo que hay detrás de `dotrino-env import`.
518
+ *
519
+ * Los valores se sellan AQUÍ, en la máquina del servicio, para quien diga el acta; la
520
+ * bóveda recibe sobres que no abre. La lista de destinatarios la firma la bóveda y se
521
+ * comprueba (`verifyResponder`): si el proxio pudiera cambiarla por sus llaves, abriría las
522
+ * privadas al pasar.
523
+ *
524
+ * Lo que decide la bóveda, no este cliente:
525
+ * · si hay quien apruebe, se pide aprobación siempre, y al aprobarse puede EDITAR;
526
+ * · si nadie puede aprobar, solo AÑADE lo que falta (una que ya exista falla con `exists`);
527
+ * · BORRAR no existe por aquí.
528
+ *
529
+ * @param {object} o
530
+ * @param {Array<{ key:string, value:string, public?:boolean }>} o.vars
531
+ * @param {string} [o.dir] identidad de `dotrino-env` (`service-identity.json`); o, en su
532
+ * lugar, la identidad por parámetros (`ns`, `proxyUrl`, `masterPubkey`, `device`, `cert`),
533
+ * que es como la trae un agente de remote-agent.
534
+ * @returns {Promise<{ keys: string[], pending: string|null, approval: boolean }>}
535
+ * `pending`: el id del pedido de aprobación (se guarda al aprobar); `keys`: lo guardado ya.
536
+ */
537
+ export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert, vars, timeoutMs = 30000 } = {}) {
538
+ const saved = dir ? readServiceIdentity(dir) : null
539
+ ns = ns || saved?.ns
540
+ proxyUrl = proxyUrl || saved?.proxy
541
+ masterPubkey = masterPubkey || saved?.iss
542
+ device = device || saved?.device
543
+ cert = cert || saved?.cert
544
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
545
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
546
+ if (!Array.isArray(vars) || !vars.length) throw new Error('storeVars: no variables to store')
547
+ for (const v of vars) {
548
+ if (typeof v?.key !== 'string' || !v.key) throw new Error('storeVars: every variable needs a key')
549
+ if (typeof v.value !== 'string' || !v.value) throw Object.assign(new Error(`storeVars: "${v.key}" has no value`), { code: 'novalue' })
550
+ }
551
+
552
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
553
+ try {
554
+ await identifyAsService(client, device)
555
+ const ask = async (data) => {
556
+ const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
557
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
558
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
559
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
560
+ const res = await pending
561
+ if (res.type === MSG.ERROR) throw vaultError(res)
562
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
563
+ if (res.body?.op !== data.op.replace(/^store$/, 'store.result') || res.body?.ns !== ns) throw new Error('the vault answered something else')
564
+ return res.body
565
+ }
566
+
567
+ const { recipients, approval } = await ask({ op: 'store.recipients' })
568
+ const { buildSealedVar, authorFromDeviceKey } = await import('./admin.js')
569
+ const author = authorFromDeviceKey(device)
570
+ const items = []
571
+ for (const v of vars) {
572
+ const sealed = await buildSealedVar({
573
+ recipients: v.public ? recipients.public : recipients.private,
574
+ owner: `ns:${ns}`, key: v.key, value: v.value, author
575
+ })
576
+ items.push({ key: v.key, sealed, public: !!v.public })
577
+ }
578
+ const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
579
+ const r = await ask({ op: 'store', items, nonce })
580
+ return { keys: r.keys || [], pending: r.pending || null, approval: !!approval }
581
+ } finally { client.close() }
582
+ }
583
+
584
+ /** La huella del último cajón recibido, por `ns` + aparato (ver `fetchDigest`). */
585
+ const lastDigests = new Map()
586
+ const digestKey = (ns, pub) => ns + String.fromCharCode(0) + pub
587
+ /** La huella del cajón que este proceso recibió por última vez, o `null`. */
588
+ export function lastDigestOf (ns, publickey) { return lastDigests.get(digestKey(ns, publickey)) ?? null }
589
+
590
+ /**
591
+ * ¿CAMBIÓ LA CONFIGURACIÓN? Pregunta la HUELLA del cajón —nombres, visibilidad y generación,
592
+ * ningún valor— y por eso no pide aprobación (dueño, 2026-09-30: «revisa que no se pidan
593
+ * aprobaciones sin motivo»). Es lo que usa `watchSecretsChanges` al reconectarse; antes
594
+ * pedía el cajón entero y a un servicio con aprobación le sonaba el teléfono al dueño en
595
+ * cada despliegue del proxio.
596
+ * @returns {Promise<string>} la huella
597
+ */
598
+ export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 20000 } = {}) {
599
+ const saved = dir ? readServiceIdentity(dir) : null
600
+ ns = ns || saved?.ns
601
+ proxyUrl = proxyUrl || saved?.proxy
602
+ masterPubkey = masterPubkey || saved?.iss
603
+ device = device || saved?.device
604
+ cert = cert || saved?.cert
605
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
606
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
607
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
608
+ try {
609
+ await identifyAsService(client, device)
610
+ const data = { op: 'digest', ns, publickey: device.publickey, ts: Date.now() }
611
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
612
+ const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.digest'), timeoutMs)
613
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
614
+ const res = await pending
615
+ if (res.type === MSG.ERROR) throw vaultError(res)
616
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
617
+ if (res.body.ns !== ns || typeof res.body.digest !== 'string') throw new Error('malformed digest reply')
618
+ if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale digest reply')
619
+ return res.body.digest
620
+ } finally { client.close() }
621
+ }
622
+
515
623
  /**
516
624
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
517
625
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
@@ -667,7 +775,10 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
667
775
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
668
776
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
669
777
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
670
- .catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
778
+ .catch((e) => {
779
+ if (/timeout/.test(e.message)) throw Object.assign(new Error('approval: nobody approved the request in time'), { code: 'unanswered' })
780
+ throw e
781
+ })
671
782
  if (res.type === MSG.ERROR) throw vaultError(res)
672
783
  }
673
784
 
@@ -686,6 +797,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
686
797
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
687
798
  await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
688
799
  anotarActa(payload?.acta)
800
+ // La huella de lo que se acaba de recibir: con ella se compara después (`fetchDigest`)
801
+ // sin volver a pedir claves. Ya comprobada: va dentro del cuerpo firmado.
802
+ if (typeof body.digest === 'string') lastDigests.set(digestKey(ns, device.publickey), body.digest)
689
803
 
690
804
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
691
805
  // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
@@ -941,11 +1055,10 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
941
1055
  * `via` dice por dónde se enteró: `notice` (llegó el aviso) o `reconcile` (nadie
942
1056
  * avisó y la comparación al reconectar encontró otra configuración).
943
1057
  * @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
944
- * @param {Record<string,string>} [opts.applied] El bundle que el agente tiene EN USO.
945
- * Pasarlo es lo que permite detectar un cambio ya en la PRIMERA conexión — el que
946
- * ocurrió entre que el agente pidió su configuración y logró ponerse a escuchar. Sin
947
- * él no se pierde la protección, solo empieza una conexión más tarde: la primera se
948
- * limita a tomar la referencia.
1058
+ * @param {string} [opts.digest] La huella del cajón EN USO (`lastDigestOf(ns, pub)` tras
1059
+ * pedirlo). Con ella se detecta ya en la primera conexión un cambio ocurrido entre que el
1060
+ * agente pidió su configuración y logró ponerse a escuchar; sin ella la primera
1061
+ * comparación solo toma la referencia.
949
1062
  * @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
950
1063
  * La comparación también lo respeta, pero **aplazándose** (el aviso sí se descarta):
951
1064
  * es lo que impide que un fallo sistemático se convierta en un ciclo de reinicios,
@@ -961,7 +1074,7 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
961
1074
  * encontró un cambio.
962
1075
  */
963
1076
  export async function watchSecretsChanges ({
964
- dir, ns, onChange, onRevoked, applied, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
1077
+ dir, ns, onChange, onRevoked, digest = null, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
965
1078
  reconcileMinMs = 30000, log = () => {}
966
1079
  } = {}) {
967
1080
  const saved = dir ? readServiceIdentity(dir) : null
@@ -979,7 +1092,10 @@ export async function watchSecretsChanges ({
979
1092
  let retryTimer = null
980
1093
  // Huella de la configuración EN USO. Comparar huellas y no valores es lo que permite
981
1094
  // decir «esto no es lo que está corriendo» sin volver a manejar los secretos.
982
- let fingerprint = applied === undefined ? null : fingerprintOf(applied)
1095
+ // La referencia es la HUELLA del cajón que está EN USO (la bóveda la manda con cada entrega:
1096
+ // `lastDigestOf`). La pasa quien sabe qué aplicó; sin ella, la primera comparación solo la
1097
+ // toma. Antes era el bundle entero (`applied`), y compararlo obligaba a volver a pedirlo.
1098
+ let fingerprint = typeof digest === 'string' ? digest : null
983
1099
  let firstConnection = true
984
1100
  let lastReconcile = 0
985
1101
  let reconciling = false
@@ -1137,9 +1253,11 @@ export async function watchSecretsChanges ({
1137
1253
  return false
1138
1254
  }
1139
1255
  reconciling = true
1140
- let bundle = null
1256
+ let current = null
1141
1257
  try {
1142
- bundle = await fetchSecrets({ dir, ns })
1258
+ // LA HUELLA, NO EL CAJÓN: preguntar si cambió no es pedir las claves, y no puede
1259
+ // hacer sonar el teléfono de nadie.
1260
+ current = await fetchDigest({ dir, ns })
1143
1261
  } catch (e) {
1144
1262
  // El cert revocado mientras estaba incomunicado: el `REVOKED` firmado se perdió
1145
1263
  // igual que el aviso, y esta es la única otra forma de enterarse. Lo demás (la
@@ -1156,8 +1274,7 @@ export async function watchSecretsChanges ({
1156
1274
  reconciling = false
1157
1275
  lastReconcile = Date.now()
1158
1276
  }
1159
- const current = fingerprintOf(bundle)
1160
- if (fingerprint === null) { fingerprint = current; return false } // primera vez: solo tomar referencia
1277
+ if (fingerprint === null) { fingerprint = current; return false } // sin referencia: solo tomarla
1161
1278
  if (current === fingerprint) return false
1162
1279
  fingerprint = current
1163
1280
  lastObeyed = Date.now()
@@ -1210,16 +1327,6 @@ export async function watchSecretsChanges ({
1210
1327
  }
1211
1328
  }
1212
1329
 
1213
- /**
1214
- * Huella de un bundle. Las claves van ORDENADAS: el bundle se arma mezclando el cajón
1215
- * del scope con el del aparato, así que el mismo contenido puede llegar en otro orden y
1216
- * un cambio de orden no es un cambio de configuración.
1217
- */
1218
- function fingerprintOf (secrets) {
1219
- const pairs = Object.entries(secrets || {}).map(([k, v]) => [k, String(v)]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
1220
- return createHash('sha256').update(JSON.stringify(pairs)).digest('hex')
1221
- }
1222
-
1223
1330
  /**
1224
1331
  * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
1225
1332
  * disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
@@ -1283,6 +1390,12 @@ export function isFinal (e) {
1283
1390
  * @param {number} approvalTimeoutMs lo que dura un pedido de aprobación
1284
1391
  * @param {number} now
1285
1392
  */
1393
+ /** Cuánto esperar para volver a pedir tras `n` pedidos sin contestar: 0, 5, 10, 20… min, hasta 6 h. */
1394
+ export function unansweredDelay (n) {
1395
+ if (!n) return 0
1396
+ return Math.min(6 * 60 * 60 * 1000, 5 * 60 * 1000 * 2 ** (n - 1))
1397
+ }
1398
+
1286
1399
  export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, now = Date.now()) {
1287
1400
  if (!rangAt) return backoffMs
1288
1401
  return Math.max(backoffMs, approvalTimeoutMs - (now - rangAt))
@@ -1291,13 +1404,19 @@ export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIME
1291
1404
  export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, onRetry, onPending, onCert, publicOnly = false } = {}) {
1292
1405
  let backoff = retryMs
1293
1406
  let rangAt = 0 // cuándo timbró ESTE intento; 0 = no hubo aprobación de por medio
1407
+ let unanswered = 0
1294
1408
  const notePending = (info) => { rangAt = Date.now(); onPending?.(info) }
1295
1409
  for (;;) {
1296
1410
  try {
1297
1411
  return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, approvalTimeoutMs, onPending: notePending, onCert, publicOnly })
1298
1412
  } catch (e) {
1299
1413
  if (isFinal(e)) throw e
1300
- const wait = retryDelay(backoff, rangAt, approvalTimeoutMs)
1414
+ // UN PEDIDO QUE NADIE CONTESTÓ NO SE REPITE CADA CINCO MINUTOS (dueño, 2026-09-30:
1415
+ // «que no se pidan aprobaciones sin motivo»). Volver a pedir en cuanto vence hacía sonar
1416
+ // el teléfono toda la noche por un servicio que arrancó de madrugada: el dueño ya lo vio
1417
+ // y no lo aprobó. Se espacia —5, 10, 20, 40 min… hasta 6 h— y se sigue esperando.
1418
+ if (e?.code === 'unanswered') unanswered++
1419
+ const wait = Math.max(retryDelay(backoff, rangAt, approvalTimeoutMs), unansweredDelay(unanswered))
1301
1420
  rangAt = 0
1302
1421
  try { onRetry?.(e, wait) } catch (_) {}
1303
1422
  await new Promise((r) => setTimeout(r, wait))