@dotrino/vault 0.28.0 → 0.30.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.28.0",
3
+ "version": "0.30.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
  }
package/src/admin.js CHANGED
@@ -41,12 +41,11 @@ export const ADMIN_OPS = Object.freeze([
41
41
  // no debe poder dejar sin configuración a los servicios.
42
42
  // `var.setMany` es la MISMA operación con varias variables dentro de un solo sobre: no
43
43
  // añade permisos, quita reinicios (ver el enrutado abajo).
44
- 'vars', 'var.set', 'var.setMany',
45
- // VER un valor y sus versiones anteriores. Entran aquí —y no en la lista de lo que la
46
- // bóveda hace por su cuenta— porque la bóveda NO abre nada: entrega el sobre y la
47
- // envoltura dirigida a ESE aparato, que lo abre con su propia llave (§8.2). Sin esto,
48
- // administrar a distancia obligaba a teclear la contraseña del perfil en un navegador.
49
- 'var.reveal', 'var.history'
44
+ // Ver un valor, su histórico o volver a una versión NO están, y es a propósito: un
45
+ // aparato que administra no tiene sobres de lo privado (solo el servicio dueño y la
46
+ // recuperación). Eso lo hace la bóveda en su máquina (`secret show/history/revert`).
47
+ // No lo cablees.
48
+ 'vars', 'var.set', 'var.setMany'
50
49
  ])
51
50
 
52
51
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
@@ -217,34 +216,6 @@ export function createAdminDesk ({
217
216
  return { ok: true, result: result || { ok: true } }
218
217
  }
219
218
 
220
- // VER un valor (y sus versiones anteriores). La bóveda entrega material sellado a
221
- // ESTE aparato; abrirlo es cosa suya. Se AUDITA, porque leer un secreto a distancia
222
- // es exactamente lo que hay que poder revisar después.
223
- if (data.op === 'var.reveal' || data.op === 'var.history') {
224
- if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
225
- if (typeof vars.reveal !== 'function') return { ok: false, error: 'admin: this vault cannot show values (update it)' }
226
- const toScope = typeof data.ns === 'string' && !!data.ns
227
- const toDevice = typeof data.pub === 'string' && !!data.pub
228
- if (toScope === toDevice) return { ok: false, error: 'admin: it needs exactly one target (ns or pub)' }
229
- const target = { ns: toScope ? data.ns : null, pub: toDevice ? data.pub : null }
230
-
231
- if (data.op === 'var.history') {
232
- const result = await vars.history({ ...target, key: typeof data.key === 'string' ? data.key : null })
233
- audit('admin.var.history', { by, ns: target.ns, key: data.key || null })
234
- return { ok: true, result }
235
- }
236
- if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.reveal needs a key' }
237
- const result = await vars.reveal({
238
- ...target,
239
- key: data.key,
240
- ts: Number.isFinite(data.ts) ? data.ts : null,
241
- device: chk.device,
242
- by
243
- })
244
- audit('admin.var.reveal', { by, ns: target.ns, key: data.key, ...(data.ts ? { ts: data.ts } : {}) })
245
- return { ok: true, result }
246
- }
247
-
248
219
  // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
249
220
  // todos los certificados. Las dos cosas o ninguna.
250
221
  //
package/src/service.js CHANGED
@@ -224,47 +224,37 @@ function writeServiceIdentity (dir, obj) {
224
224
  * Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
225
225
  * @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
226
226
  */
227
- export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
227
+ /**
228
+ * EL ENROLAMIENTO, a secas: con una invitación de la bóveda (en cualquiera de sus
229
+ * formas) crea las DOS llaves del aparato —firma y cifrado—, pide el cert y lo
230
+ * verifica. **No persiste nada**: devuelve la identidad y quien llama decide dónde
231
+ * vive (`enrollService` la guarda como servicio; `@dotrino/remote-agent` como
232
+ * `link.json`). Es el único sitio del ecosistema donde se enrola un agente headless:
233
+ * si falta algo al enrolar, se añade aquí y lo heredan todos.
234
+ *
235
+ * @param {Object} opts
236
+ * @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
237
+ * @param {string} [opts.label]
238
+ * @param {string|null} [opts.expectedScope] Scope que el cert DEBE traer (null = no se exige).
239
+ * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
240
+ * @param {number} [opts.approveTimeoutMs]
241
+ * @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
242
+ */
243
+ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000 } = {}) {
228
244
  // `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.
245
+ // `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
246
+ // invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
235
247
  if (typeof qr === 'string') {
236
248
  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>`)')
249
+ if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair`)')
238
250
  qr = o
239
251
  }
240
252
  if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
241
253
  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
254
 
261
255
  const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
262
256
  // QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
263
257
  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
258
  const target = await resolveAppointment(client, qr.conn)
269
259
  const hello = await new Promise((resolve, reject) => {
270
260
  const off = client.on('message', (_f, p) => {
@@ -279,22 +269,14 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
279
269
  }
280
270
  try {
281
271
  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.
272
+ // La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
273
+ // aparato entra al acta pero no le llega ningún secreto, y no da error.
286
274
  const enc = await makeDeviceEncKey()
287
275
  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).
276
+ // Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
277
+ // aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
290
278
  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
279
  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
280
  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
281
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
300
282
 
@@ -302,7 +284,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
302
284
  const off = client.on('message', (_from, p) => {
303
285
  if (!p || typeof p !== 'object') return
304
286
  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}`))
287
+ const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
306
288
  show({ deviceId, code })
307
289
  } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
308
290
  })
@@ -312,35 +294,71 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
312
294
  client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
313
295
  const res = await enrolled
314
296
 
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
297
  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) })
298
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
319
299
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
320
300
  if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
321
301
 
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 }
302
+ return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
328
303
  } finally { client.close() }
329
304
  }
330
305
 
331
306
  /**
332
- * Se asegura de que este servicio TENGA llave de cifrado, generándola si le falta.
307
+ * Enrola ESTE servicio contra el vault y persiste su identidad.
333
308
  *
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.
309
+ * UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
310
+ * aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
311
+ * adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
312
+ * el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
313
+ * había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
314
+ * es un servicio, no una persona; no tiene por qué "ser varios".
339
315
  *
340
- * La llave de FIRMA no se toca: de ella sale el `nodeId` del proxio.
316
+ * Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
317
+ * es además la forma de rotar la identidad de un agente comprometido. Lo que sí
318
+ * hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
341
319
  *
342
- * @returns {Promise<{ encPub: string, created: boolean }>}
320
+ * En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
321
+ * que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
322
+ * Muestra un código por `onCode`: el dueño lo tipea en el vault
323
+ * (`dotrino-vault approve <código>`).
324
+ *
325
+ * @param {Object} opts
326
+ * @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
327
+ * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
328
+ * @param {string} opts.dir Dónde persistir `service-identity.json`.
329
+ * @param {string} [opts.label]
330
+ * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
331
+ * @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
332
+ * Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
333
+ * @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
343
334
  */
335
+ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
336
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
337
+ if (!dir) throw new Error('dir required (where to persist the service identity)')
338
+ label = label || 'service:' + ns
339
+
340
+ // La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
341
+ // el proxy, por ejemplo, esta llave es además su identidad de red, así que
342
+ // reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
343
+ // que se re-pineen a mano.
344
+ const previous = readServiceIdentity(dir)
345
+ let replaced = null
346
+ if (previous?.device?.publickey) {
347
+ replaced = {
348
+ ns: previous.ns,
349
+ enrolledAt: previous.enrolledAt,
350
+ deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
351
+ }
352
+ try { onReplace?.(replaced) } catch (_) {}
353
+ }
354
+
355
+ const { device, enc, cert, iss, proxy } = await enrollWithVault({ qr, label, expectedScope: secretsScope(ns), onCode, approveTimeoutMs })
356
+ // v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
357
+ // id de nodo del proxio y la fila del acta.
358
+ writeServiceIdentity(dir, { v: 2, ns, iss, proxy, device, enc, cert, enrolledAt: Date.now() })
359
+ return { device, enc, cert, iss, replaced }
360
+ }
361
+
344
362
  export async function ensureEncKey ({ dir } = {}) {
345
363
  const saved = readServiceIdentity(dir)
346
364
  if (!saved) throw new Error('service not enrolled: run enrollService() first')
@@ -389,9 +407,13 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
389
407
  * Renueva el cert automáticamente si está por vencer (best-effort).
390
408
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
391
409
  */
392
- export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
410
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000 } = {}) {
393
411
  let saved = null
394
412
  if (dir) saved = readServiceIdentity(dir)
413
+ // Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
414
+ // enrolado por `@dotrino/remote-agent` (su `link.json` trae `device`, `cert` y `enc`).
415
+ // Un mismo enrolamiento sirve para el plano de control y para los secretos.
416
+ if (!saved && enc) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert, enc }
395
417
  ns = ns || saved?.ns
396
418
  proxyUrl = proxyUrl || saved?.proxy
397
419
  masterPubkey = masterPubkey || saved?.iss
@@ -860,11 +882,11 @@ function fingerprintOf (secrets) {
860
882
  * servicio no opera hasta que esto resuelva — esa es la regla.
861
883
  * @returns {Promise<Record<string,string>>}
862
884
  */
863
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
885
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
864
886
  let delay = retryMs
865
887
  for (;;) {
866
888
  try {
867
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
889
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
868
890
  } catch (e) {
869
891
  // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
870
892
  // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.