@dotrino/vault 0.29.0 → 0.31.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/package.json +5 -2
- package/src/enroll.js +4 -1
- package/src/env.js +2 -2
- package/src/protocol.js +2 -1
- package/src/service.js +104 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"./sealed": {
|
|
25
25
|
"import": "./src/sealed.js"
|
|
26
26
|
},
|
|
27
|
+
"./invite": {
|
|
28
|
+
"import": "./src/invite.js"
|
|
29
|
+
},
|
|
27
30
|
"./protocol": {
|
|
28
31
|
"import": "./src/protocol.js"
|
|
29
32
|
}
|
|
@@ -43,7 +46,7 @@
|
|
|
43
46
|
"pairing"
|
|
44
47
|
],
|
|
45
48
|
"peerDependencies": {
|
|
46
|
-
"@dotrino/identity": "
|
|
49
|
+
"@dotrino/identity": "^0.58.0",
|
|
47
50
|
"@dotrino/proxy-client": ">=0.9.0"
|
|
48
51
|
},
|
|
49
52
|
"license": "MIT",
|
package/src/enroll.js
CHANGED
|
@@ -341,8 +341,11 @@ export function createEnrollDesk ({
|
|
|
341
341
|
let record = null
|
|
342
342
|
try {
|
|
343
343
|
if (typeof identity.admitMember === 'function') {
|
|
344
|
+
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
345
|
+
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
346
|
+
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
344
347
|
const cn = scopeToCn(pend.scope)
|
|
345
|
-
const caps = cn ? ['secrets'] :
|
|
348
|
+
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
346
349
|
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
347
350
|
}
|
|
348
351
|
record = (await identity.profileActa?.())?.acta || null
|
package/src/env.js
CHANGED
|
@@ -98,12 +98,12 @@ let lastApplied = null
|
|
|
98
98
|
* `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
|
|
99
99
|
* el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
|
|
100
100
|
*/
|
|
101
|
-
export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
|
|
101
|
+
export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
|
|
102
102
|
if (override === undefined) override = overrideByDefault()
|
|
103
103
|
ns = resolveNs(ns)
|
|
104
104
|
dir = dir || serviceDir(ns)
|
|
105
105
|
const load = wait ? waitForSecrets : fetchSecrets
|
|
106
|
-
const secrets = await load({ dir, ns, onRetry })
|
|
106
|
+
const secrets = await load({ dir, ns, onRetry, onPending })
|
|
107
107
|
|
|
108
108
|
const missing = required.filter((k) => !(k in secrets))
|
|
109
109
|
if (missing.length) {
|
package/src/protocol.js
CHANGED
|
@@ -97,7 +97,8 @@ export const SCOPE = Object.freeze({
|
|
|
97
97
|
// Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
|
|
98
98
|
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
99
99
|
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
100
|
-
ADMIN: 'vault:admin'
|
|
100
|
+
ADMIN: 'vault:admin',
|
|
101
|
+
APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
101
102
|
})
|
|
102
103
|
|
|
103
104
|
/**
|
package/src/service.js
CHANGED
|
@@ -161,6 +161,9 @@ async function identifyAsService (client, device) {
|
|
|
161
161
|
await client.identify({ data, signature })
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** Cuánto espera un agente a que alguien apruebe (el pedido vive 5 min en la bóveda). */
|
|
165
|
+
export const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 + 10 * 1000
|
|
166
|
+
|
|
164
167
|
function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
165
168
|
return new Promise((resolve, reject) => {
|
|
166
169
|
const off = client.on('message', (_from, payload) => {
|
|
@@ -224,47 +227,37 @@ function writeServiceIdentity (dir, obj) {
|
|
|
224
227
|
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
225
228
|
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
226
229
|
*/
|
|
227
|
-
|
|
230
|
+
/**
|
|
231
|
+
* EL ENROLAMIENTO, a secas: con una invitación de la bóveda (en cualquiera de sus
|
|
232
|
+
* formas) crea las DOS llaves del aparato —firma y cifrado—, pide el cert y lo
|
|
233
|
+
* verifica. **No persiste nada**: devuelve la identidad y quien llama decide dónde
|
|
234
|
+
* vive (`enrollService` la guarda como servicio; `@dotrino/remote-agent` como
|
|
235
|
+
* `link.json`). Es el único sitio del ecosistema donde se enrola un agente headless:
|
|
236
|
+
* si falta algo al enrolar, se añade aquí y lo heredan todos.
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} opts
|
|
239
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
240
|
+
* @param {string} [opts.label]
|
|
241
|
+
* @param {string|null} [opts.expectedScope] Scope que el cert DEBE traer (null = no se exige).
|
|
242
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
243
|
+
* @param {number} [opts.approveTimeoutMs]
|
|
244
|
+
* @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
|
|
245
|
+
*/
|
|
246
|
+
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000 } = {}) {
|
|
228
247
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
229
|
-
// `dotrino-vault pair
|
|
230
|
-
//
|
|
231
|
-
// «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
|
|
232
|
-
// este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
|
|
233
|
-
// cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
|
|
234
|
-
// incluida la vieja, así que esto entiende cualquier invitación.
|
|
248
|
+
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
249
|
+
// invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
|
|
235
250
|
if (typeof qr === 'string') {
|
|
236
251
|
const o = parseInvite(qr)
|
|
237
|
-
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair
|
|
252
|
+
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair`)')
|
|
238
253
|
qr = o
|
|
239
254
|
}
|
|
240
255
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
|
|
241
256
|
rejectAdoption(qr.m)
|
|
242
|
-
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
243
|
-
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
244
|
-
label = label || 'service:' + ns
|
|
245
|
-
|
|
246
|
-
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
247
|
-
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
248
|
-
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
249
|
-
// que se re-pineen a mano.
|
|
250
|
-
const previous = readServiceIdentity(dir)
|
|
251
|
-
let replaced = null
|
|
252
|
-
if (previous?.device?.publickey) {
|
|
253
|
-
replaced = {
|
|
254
|
-
ns: previous.ns,
|
|
255
|
-
enrolledAt: previous.enrolledAt,
|
|
256
|
-
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
257
|
-
}
|
|
258
|
-
try { onReplace?.(replaced) } catch (_) {}
|
|
259
|
-
}
|
|
260
257
|
|
|
261
258
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
262
259
|
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
263
260
|
if (!qr.iss) {
|
|
264
|
-
// `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
|
|
265
|
-
// canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
|
|
266
|
-
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
267
|
-
// aunque la bóveda esté en otro proxio de la malla.
|
|
268
261
|
const target = await resolveAppointment(client, qr.conn)
|
|
269
262
|
const hello = await new Promise((resolve, reject) => {
|
|
270
263
|
const off = client.on('message', (_f, p) => {
|
|
@@ -279,22 +272,14 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
279
272
|
}
|
|
280
273
|
try {
|
|
281
274
|
const device = await makeDeviceKey({ label })
|
|
282
|
-
// La llave de CIFRADO
|
|
283
|
-
//
|
|
284
|
-
// que nadie sepa por qué. Su pública viaja en el enrolamiento y acaba en el
|
|
285
|
-
// acta como `encPub`; la privada no sale de este disco.
|
|
275
|
+
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
276
|
+
// aparato entra al acta pero no le llega ningún secreto, y no da error.
|
|
286
277
|
const enc = await makeDeviceEncKey()
|
|
287
278
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
288
|
-
// Código ALEATORIO
|
|
289
|
-
//
|
|
279
|
+
// Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
|
|
280
|
+
// aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
|
|
290
281
|
const code = makePairingCode()
|
|
291
|
-
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
292
|
-
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
293
282
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
294
|
-
// `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
|
|
295
|
-
// si falta, asume `join` — pero un agente no debe apoyarse en un default
|
|
296
|
-
// para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
|
|
297
|
-
// firmado: nadie en el medio puede convertirlo en una adopción.
|
|
298
283
|
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc.encPublickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
299
284
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
300
285
|
|
|
@@ -302,7 +287,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
302
287
|
const off = client.on('message', (_from, p) => {
|
|
303
288
|
if (!p || typeof p !== 'object') return
|
|
304
289
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
305
|
-
const show = onCode || (({ deviceId, code }) => console.log(`[vault
|
|
290
|
+
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
306
291
|
show({ deviceId, code })
|
|
307
292
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
308
293
|
})
|
|
@@ -312,35 +297,71 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
312
297
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
313
298
|
const res = await enrolled
|
|
314
299
|
|
|
315
|
-
// Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
|
|
316
|
-
// para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
|
|
317
300
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
318
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope:
|
|
301
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
319
302
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
320
303
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
321
304
|
|
|
322
|
-
|
|
323
|
-
// anterior deja de existir en este agente.
|
|
324
|
-
// v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
|
|
325
|
-
// `nodeId` del proxio, y cambiarlo le costaría su cajón de variables.
|
|
326
|
-
writeServiceIdentity(dir, { v: 2, ns, iss: qr.iss, proxy: qr.proxy, device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, enrolledAt: Date.now() })
|
|
327
|
-
return { device, enc, cert: res.cert, iss: qr.iss, replaced }
|
|
305
|
+
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
328
306
|
} finally { client.close() }
|
|
329
307
|
}
|
|
330
308
|
|
|
331
309
|
/**
|
|
332
|
-
*
|
|
310
|
+
* Enrola ESTE servicio contra el vault y persiste su identidad.
|
|
333
311
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
312
|
+
* UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
|
|
313
|
+
* aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
|
|
314
|
+
* adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
|
|
315
|
+
* el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
|
|
316
|
+
* había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
|
|
317
|
+
* es un servicio, no una persona; no tiene por qué "ser varios".
|
|
339
318
|
*
|
|
340
|
-
*
|
|
319
|
+
* Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
|
|
320
|
+
* es además la forma de rotar la identidad de un agente comprometido. Lo que sí
|
|
321
|
+
* hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
|
|
341
322
|
*
|
|
342
|
-
*
|
|
323
|
+
* En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
|
|
324
|
+
* que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
|
|
325
|
+
* Muestra un código por `onCode`: el dueño lo tipea en el vault
|
|
326
|
+
* (`dotrino-vault approve <código>`).
|
|
327
|
+
*
|
|
328
|
+
* @param {Object} opts
|
|
329
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
330
|
+
* @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
|
|
331
|
+
* @param {string} opts.dir Dónde persistir `service-identity.json`.
|
|
332
|
+
* @param {string} [opts.label]
|
|
333
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
334
|
+
* @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
|
|
335
|
+
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
336
|
+
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
343
337
|
*/
|
|
338
|
+
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
339
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
340
|
+
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
341
|
+
label = label || 'service:' + ns
|
|
342
|
+
|
|
343
|
+
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
344
|
+
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
345
|
+
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
346
|
+
// que se re-pineen a mano.
|
|
347
|
+
const previous = readServiceIdentity(dir)
|
|
348
|
+
let replaced = null
|
|
349
|
+
if (previous?.device?.publickey) {
|
|
350
|
+
replaced = {
|
|
351
|
+
ns: previous.ns,
|
|
352
|
+
enrolledAt: previous.enrolledAt,
|
|
353
|
+
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
354
|
+
}
|
|
355
|
+
try { onReplace?.(replaced) } catch (_) {}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const { device, enc, cert, iss, proxy } = await enrollWithVault({ qr, label, expectedScope: secretsScope(ns), onCode, approveTimeoutMs })
|
|
359
|
+
// v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
|
|
360
|
+
// id de nodo del proxio y la fila del acta.
|
|
361
|
+
writeServiceIdentity(dir, { v: 2, ns, iss, proxy, device, enc, cert, enrolledAt: Date.now() })
|
|
362
|
+
return { device, enc, cert, iss, replaced }
|
|
363
|
+
}
|
|
364
|
+
|
|
344
365
|
export async function ensureEncKey ({ dir } = {}) {
|
|
345
366
|
const saved = readServiceIdentity(dir)
|
|
346
367
|
if (!saved) throw new Error('service not enrolled: run enrollService() first')
|
|
@@ -389,9 +410,13 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
389
410
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
390
411
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
391
412
|
*/
|
|
392
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
413
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
|
|
393
414
|
let saved = null
|
|
394
415
|
if (dir) saved = readServiceIdentity(dir)
|
|
416
|
+
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
417
|
+
// enrolado por `@dotrino/remote-agent` (su `link.json` trae `device`, `cert` y `enc`).
|
|
418
|
+
// Un mismo enrolamiento sirve para el plano de control y para los secretos.
|
|
419
|
+
if (!saved && enc) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert, enc }
|
|
395
420
|
ns = ns || saved?.ns
|
|
396
421
|
proxyUrl = proxyUrl || saved?.proxy
|
|
397
422
|
masterPubkey = masterPubkey || saved?.iss
|
|
@@ -426,9 +451,22 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
426
451
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
427
452
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
428
453
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
429
|
-
|
|
454
|
+
let res = await pending
|
|
430
455
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
431
456
|
|
|
457
|
+
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
458
|
+
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
459
|
+
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
460
|
+
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
461
|
+
const okPending = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
|
|
462
|
+
if (!okPending) throw new Error('invalid master signature on the pending reply')
|
|
463
|
+
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
464
|
+
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
465
|
+
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
466
|
+
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
|
|
467
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
468
|
+
}
|
|
469
|
+
|
|
432
470
|
// Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
|
|
433
471
|
const body = res.body
|
|
434
472
|
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
@@ -860,15 +898,16 @@ function fingerprintOf (secrets) {
|
|
|
860
898
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
861
899
|
* @returns {Promise<Record<string,string>>}
|
|
862
900
|
*/
|
|
863
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
|
|
901
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
|
|
864
902
|
let delay = retryMs
|
|
865
903
|
for (;;) {
|
|
866
904
|
try {
|
|
867
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
|
|
905
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
|
|
868
906
|
} catch (e) {
|
|
869
907
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
870
908
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
|
871
|
-
|
|
909
|
+
// Que te lo DENIEGUEN tampoco se reintenta: fue una decisión, no un tropiezo.
|
|
910
|
+
if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e.message)) throw e
|
|
872
911
|
try { onRetry?.(e, delay) } catch (_) {}
|
|
873
912
|
await new Promise((r) => setTimeout(r, delay))
|
|
874
913
|
delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
|