@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.
- package/bin/dotrino-env.js +88 -1
- package/package.json +1 -1
- package/src/env.js +6 -2
- package/src/service.js +142 -23
package/bin/dotrino-env.js
CHANGED
|
@@ -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.
|
|
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) => {
|
|
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 {
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
1256
|
+
let current = null
|
|
1141
1257
|
try {
|
|
1142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|