@dotrino/vault 0.30.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 +2 -2
- package/src/enroll.js +4 -1
- package/src/env.js +2 -2
- package/src/protocol.js +2 -1
- package/src/service.js +22 -5
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"pairing"
|
|
47
47
|
],
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@dotrino/identity": "
|
|
49
|
+
"@dotrino/identity": "^0.58.0",
|
|
50
50
|
"@dotrino/proxy-client": ">=0.9.0"
|
|
51
51
|
},
|
|
52
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) => {
|
|
@@ -407,7 +410,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
407
410
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
408
411
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
409
412
|
*/
|
|
410
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000 } = {}) {
|
|
413
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
|
|
411
414
|
let saved = null
|
|
412
415
|
if (dir) saved = readServiceIdentity(dir)
|
|
413
416
|
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
@@ -448,9 +451,22 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
448
451
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
449
452
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
450
453
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
451
|
-
|
|
454
|
+
let res = await pending
|
|
452
455
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
453
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
|
+
|
|
454
470
|
// Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
|
|
455
471
|
const body = res.body
|
|
456
472
|
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
@@ -882,15 +898,16 @@ function fingerprintOf (secrets) {
|
|
|
882
898
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
883
899
|
* @returns {Promise<Record<string,string>>}
|
|
884
900
|
*/
|
|
885
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
|
|
901
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
|
|
886
902
|
let delay = retryMs
|
|
887
903
|
for (;;) {
|
|
888
904
|
try {
|
|
889
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
|
|
905
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
|
|
890
906
|
} catch (e) {
|
|
891
907
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
892
908
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
|
893
|
-
|
|
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
|
|
894
911
|
try { onRetry?.(e, delay) } catch (_) {}
|
|
895
912
|
await new Promise((r) => setTimeout(r, delay))
|
|
896
913
|
delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
|