@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.29.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": ">=0.56.0",
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'] : 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) => {
@@ -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
- export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
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 --service` emite la URL del QR y el código compacto
230
- // (`c…`/`t…`, ver invite.js), así que un `JSON.parse` fallaba SIEMPRE con
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 --service <ns>`)')
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, hermana de la de firma. Sin ella el vault no puede
283
- // sellarle sus variables a ESTE aparato, y arrancaría sin configuración sin
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 generado AQUÍ: el vault no lo conoce; solo puede echarlo
289
- // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
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-service] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
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: secretsScope(ns) })
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
- // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
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
- * Se asegura de que este servicio TENGA llave de cifrado, generándola si le falta.
310
+ * Enrola ESTE servicio contra el vault y persiste su identidad.
333
311
  *
334
- * Existe para los agentes que se enrolaron ANTES de que la llave existiera: su
335
- * `service-identity.json` es v1 y solo lleva la de firma. Sin esto, la única forma de
336
- * conseguirla sería re-enrolarse — y re-enrolar le cambia la pubkey, con lo que pierde
337
- * su cajón de variables (va indexado por ella) y arrancaría sin configuración, en
338
- * silencio. Ese es justo el accidente que esta función existe para evitar.
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
- * La llave de FIRMA no se toca: de ella sale el `nodeId` del proxio.
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
- * @returns {Promise<{ encPub: string, created: boolean }>}
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
- const res = await pending
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
- 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
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))