@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.30.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": ">=0.56.0",
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'] : scopeToCaps(pend.scope)
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
- const res = await pending
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
- if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
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))