@dotrino/vaultd 0.38.0 → 0.46.2

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/README.md CHANGED
@@ -239,6 +239,9 @@ dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma
239
239
  dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
240
240
  dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
241
241
  dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
242
+ dotrino-vault pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
243
+ # Se combina con --service: `--service eco --scope sign` = un bot que firma
244
+ # como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
242
245
  dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
243
246
  # aparatos que sirven ese namespace
244
247
  dotrino-vault secret set <ns> CLAVE=valor CLAVE2=valor2 … # VARIAS de una vez (un solo aviso)
@@ -313,7 +316,7 @@ dos.
313
316
  ```sh
314
317
  dotrino-vault secret set web PUBLIC_URL https://ejemplo.com --public
315
318
  dotrino-vault secret set web API_KEY sk-… # sin bandera: privada
316
- dotrino-vault secret visibility web PUBLIC_URL private # cambiarlo sin tocar el valor
319
+ dotrino-vault secret visibility web PUBLIC_URL private # taparla sin tocar el valor (privada → pública no existe)
317
320
  ```
318
321
 
319
322
  - **Se nace privada.** Y **rotar el valor conserva la visibilidad**: exponer un secreto
package/lib/src/admin.js CHANGED
@@ -41,6 +41,10 @@ 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
+ // 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.
44
48
  'vars', 'var.set', 'var.setMany'
45
49
  ])
46
50
 
package/lib/src/atrest.js CHANGED
Binary file
package/lib/src/enroll.js CHANGED
@@ -261,9 +261,15 @@ export function createEnrollDesk ({
261
261
  pend.dpub = d.dpub
262
262
  pend.deviceId = deviceId
263
263
  pend.commit = d.commit
264
- // Llave de CIFRADO del dispositivo: con ella se le envuelve la clave de contenido del
265
- // perfil al admitirlo. Sin ella entra, pero no podrá leer lo que haya guardado.
264
+ // Llave de CIFRADO del dispositivo: con ella se le envuelve la clave del cajón al
265
+ // admitirlo. Un SERVICIO sin ella entraría al acta y no podría leer NUNCA ninguna
266
+ // variable —las privadas van selladas a esta llave—, así que se corta aquí en vez
267
+ // de admitirlo y dejar que falle más tarde y en otro sitio. Un dispositivo de
268
+ // persona sí puede entrar sin ella: no lee variables de servicio.
266
269
  if (typeof d.encPub === 'string') pend.encPub = d.encPub
270
+ if (!pend.encPub && scopeToCn(pend.scope)) {
271
+ return reply(from, { type: MSG_ERROR, error: 'a service must send its encryption key (update @dotrino/vault on the service)' })
272
+ }
267
273
  // Certificado de continuidad (opcional): lo firma la identidad que se une, con su
268
274
  // propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
269
275
  if (d.continuity) {
@@ -335,8 +341,11 @@ export function createEnrollDesk ({
335
341
  let record = null
336
342
  try {
337
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.
338
347
  const cn = scopeToCn(pend.scope)
339
- const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
348
+ const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
340
349
  if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
341
350
  }
342
351
  record = (await identity.profileActa?.())?.acta || null
@@ -66,6 +66,16 @@ export const MSG = Object.freeze({
66
66
  // Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
67
67
  // aviso de reinicio sin autenticar ES un ataque de denegación.
68
68
  SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
69
+ // REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
70
+ // bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
71
+ // tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
72
+ //
73
+ // El servicio NO se fía de lo que le manden: comprueba la firma de la maestra, comprueba
74
+ // el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
75
+ // destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
76
+ // llave para alguien que la maestra no haya metido en su propio cajón.
77
+ REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, signature }
78
+ REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
69
79
  // --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
70
80
  // Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
71
81
  // Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
@@ -22,9 +22,11 @@ import fs from 'node:fs'
22
22
  import path from 'node:path'
23
23
  import { createHash } from 'node:crypto'
24
24
  import {
25
- makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig,
26
- makePairingCode, commitCode, pubkeyId
25
+ makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
26
+ verifyDeviceSig, makePairingCode, commitCode, pubkeyId
27
27
  } from '@dotrino/identity/capabilities'
28
+ import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
29
+ import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
28
30
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
29
31
  import { makeEphemeralKey, openSealed } from './sealed.js'
30
32
  import { parseInvite } from './invite.js'
@@ -222,47 +224,37 @@ function writeServiceIdentity (dir, obj) {
222
224
  * Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
223
225
  * @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
224
226
  */
225
- 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 } = {}) {
226
244
  // `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
227
- // `dotrino-vault pair --service` emite la URL del QR y el código compacto
228
- // (`c…`/`t…`, ver invite.js), así que un `JSON.parse` fallaba SIEMPRE con
229
- // «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
230
- // este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
231
- // cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
232
- // 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.
233
247
  if (typeof qr === 'string') {
234
248
  const o = parseInvite(qr)
235
- 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`)')
236
250
  qr = o
237
251
  }
238
252
  if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
239
253
  rejectAdoption(qr.m)
240
- if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
241
- if (!dir) throw new Error('dir required (where to persist the service identity)')
242
- label = label || 'service:' + ns
243
-
244
- // La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
245
- // el proxy, por ejemplo, esta llave es además su identidad de red, así que
246
- // reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
247
- // que se re-pineen a mano.
248
- const previous = readServiceIdentity(dir)
249
- let replaced = null
250
- if (previous?.device?.publickey) {
251
- replaced = {
252
- ns: previous.ns,
253
- enrolledAt: previous.enrolledAt,
254
- deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
255
- }
256
- try { onReplace?.(replaced) } catch (_) {}
257
- }
258
254
 
259
255
  const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
260
256
  // QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
261
257
  if (!qr.iss) {
262
- // `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
263
- // canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
264
- // que la emitió —lo dice el prefijo del propio código—, así que funciona
265
- // aunque la bóveda esté en otro proxio de la malla.
266
258
  const target = await resolveAppointment(client, qr.conn)
267
259
  const hello = await new Promise((resolve, reject) => {
268
260
  const off = client.on('message', (_f, p) => {
@@ -277,25 +269,22 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
277
269
  }
278
270
  try {
279
271
  const device = await makeDeviceKey({ label })
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.
274
+ const enc = await makeDeviceEncKey()
280
275
  const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
281
- // Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
282
- // 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.
283
278
  const code = makePairingCode()
284
- // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
285
- // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
286
279
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
287
- // `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
288
- // si falta, asume `join` — pero un agente no debe apoyarse en un default
289
- // para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
290
- // firmado: nadie en el medio puede convertirlo en una adopción.
291
- const data = { op: 'enroll', intent: 'join', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
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() }
292
281
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
293
282
 
294
283
  const enrolled = new Promise((resolve, reject) => {
295
284
  const off = client.on('message', (_from, p) => {
296
285
  if (!p || typeof p !== 'object') return
297
286
  if (p.type === MSG.ENROLL_CHALLENGE) {
298
- 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}`))
299
288
  show({ deviceId, code })
300
289
  } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
301
290
  })
@@ -305,17 +294,110 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
305
294
  client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
306
295
  const res = await enrolled
307
296
 
308
- // Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
309
- // para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
310
297
  if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
311
- 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 } : {}) })
312
299
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
313
300
  if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
314
301
 
315
- // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
316
- // anterior deja de existir en este agente.
317
- writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
318
- return { device, 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' }
303
+ } finally { client.close() }
304
+ }
305
+
306
+ /**
307
+ * Enrola ESTE servicio contra el vault y persiste su identidad.
308
+ *
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".
315
+ *
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.
319
+ *
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}>}
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
+
362
+ export async function ensureEncKey ({ dir } = {}) {
363
+ const saved = readServiceIdentity(dir)
364
+ if (!saved) throw new Error('service not enrolled: run enrollService() first')
365
+ if (saved.enc?.publickey && saved.enc?.privateJwk) return { encPub: saved.enc.publickey, created: false }
366
+ const enc = await makeDeviceEncKey()
367
+ writeServiceIdentity(dir, { ...saved, v: 2, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } })
368
+ return { encPub: enc.encPublickey, created: true }
369
+ }
370
+
371
+ /**
372
+ * Registra en la bóveda la llave de cifrado de ESTE servicio, para que pueda sellarle
373
+ * sus variables. Genera la llave si falta.
374
+ *
375
+ * Va por `MSG.SECRETS` con `op:'enckey'` a propósito: no hace falta una constante nueva
376
+ * del protocolo, y así el trío de archivos vendorizado en el iframe de identidad no se
377
+ * mueve. Registrar una llave no da acceso a nada por sí solo —quien firma esta petición
378
+ * ya tiene la llave de firma del servicio, o sea ya lee ese namespace—, así que no exige
379
+ * la contraseña del perfil.
380
+ */
381
+ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
382
+ const saved = readServiceIdentity(dir)
383
+ const { encPub, created } = await ensureEncKey({ dir })
384
+ ns = ns || saved?.ns
385
+ proxyUrl = proxyUrl || saved?.proxy
386
+ masterPubkey = masterPubkey || saved?.iss
387
+ device = device || saved?.device
388
+ cert = cert || saved?.cert
389
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
390
+
391
+ const client = await freshClient(proxyUrl)
392
+ try {
393
+ await identifyAsService(client, device)
394
+ const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
395
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
396
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
397
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
398
+ const res = await pending
399
+ if (res.type === MSG.ERROR) throw new Error(res.error)
400
+ return { encPub, created, ok: true }
319
401
  } finally { client.close() }
320
402
  }
321
403
 
@@ -325,9 +407,13 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
325
407
  * Renueva el cert automáticamente si está por vencer (best-effort).
326
408
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
327
409
  */
328
- 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 } = {}) {
329
411
  let saved = null
330
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 }
331
417
  ns = ns || saved?.ns
332
418
  proxyUrl = proxyUrl || saved?.proxy
333
419
  masterPubkey = masterPubkey || saved?.iss
@@ -373,11 +459,110 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
373
459
  if (!ok) throw new Error('invalid master signature on the secrets reply')
374
460
 
375
461
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
462
+
463
+ // DOS CAPAS DE SOBRE, y hacen cosas distintas:
464
+ // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
465
+ // ni los nombres de tus variables;
466
+ // · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
467
+ // sin poder abrirlo.
468
+ // Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
469
+ if (payload?.sealed) return openSealedBundle(payload.sealed, saved, payload.acta, masterPubkey)
470
+
471
+ // Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
472
+ // cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
376
473
  if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
377
474
  return payload.secrets
378
475
  } finally { client.close() }
379
476
  }
380
477
 
478
+ /**
479
+ * Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
480
+ * descifra con ella las variables privadas. Las públicas vienen en claro.
481
+ *
482
+ * Un fallo al abrir es un ERROR DURO, nunca un salto a lo del scope ni un valor
483
+ * omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
484
+ * con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
485
+ */
486
+ /**
487
+ * ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
488
+ *
489
+ * Envolver una llave solo necesita públicas, así que **cualquiera puede fabricar un sobre
490
+ * válido** para este servicio: abrirlo prueba que es para mí, no que lo escribió quien
491
+ * debía. Lo que lo prueba es la firma, hecha con la llave de sellado que el acta nombra
492
+ * para el `seq` con el que se firmó — y el acta la firma la maestra, que es la que este
493
+ * agente lleva pineada desde que se enroló.
494
+ *
495
+ * Una firma que NO cuadra es un error duro: es exactamente el caso que esto viene a
496
+ * cazar. Un sobre SIN firma se acepta y se avisa: los hay de antes de que esto existiera
497
+ * y negarse a arrancar por eso apagaría servicios que llevan meses bien.
498
+ */
499
+ export async function makeSealCheck (acta, masterPubkey, log = console.log) {
500
+ if (!acta) return () => {}
501
+ // El acta tiene que venir firmada por la maestra que este agente ya conoce. Si la
502
+ // selló otro (un traspaso que este agente no ha visto), no se puede establecer
503
+ // procedencia: se dice y se sigue, en vez de fingir que se comprobó.
504
+ const ok = acta.sealedBy === masterPubkey && (await verifyActa({ acta })).ok
505
+ if (!ok) {
506
+ log('[vault] ⚠ the record does not come from the master this agent knows: envelope provenance NOT checked')
507
+ return () => {}
508
+ }
509
+ let avisado = false
510
+ return async (owner, key, gen, e, seal) => {
511
+ if (!seal?.sig) {
512
+ if (!avisado) { avisado = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
513
+ return
514
+ }
515
+ const pub = sealKeyAt(acta, seal.seq)
516
+ if (!pub) throw new Error(`${key}: the record has no sealing key for #${seal.seq} (the envelope claims a record that does not exist)`)
517
+ const good = await verifyDeviceSig({ publickey: pub, data: { owner, key, gen, iv: e.iv, ct: e.ct }, signature: seal.sig })
518
+ if (!good) throw new Error(`${key}: the envelope signature does not check out — it did not come from this vault`)
519
+ }
520
+ }
521
+
522
+ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null) {
523
+ if (!ident?.enc?.privateJwk) {
524
+ throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
525
+ }
526
+ const mine = await importDeviceEncKey(ident.enc.privateJwk)
527
+
528
+ // UNA LLAVE POR GENERACIÓN, no una por cajón. Desde v5 cada escritura estrena
529
+ // generación —la bóveda no puede reutilizar una llave que no puede abrir—, así que dos
530
+ // variables del mismo cajón pueden venir de generaciones distintas. El bundle trae
531
+ // TODAS las envolturas de este aparato; se abren perezosamente, solo las que hagan
532
+ // falta. `sealed.ns`/`sealed.dev` (una sola, la vigente) siguen entrando: es el bundle
533
+ // de v4 y sirve para lo que ese vault selló.
534
+ const porGen = { ns: new Map(), dev: new Map() }
535
+ const añade = (cual, info) => { if (info?.wrap) porGen[cual].set(info.gen ?? 0, info.wrap) }
536
+ añade('ns', sealed.ns); añade('dev', sealed.dev)
537
+ for (const cual of ['ns', 'dev']) for (const info of sealed.wraps?.[cual] || []) añade(cual, info)
538
+
539
+ const abiertas = { ns: new Map(), dev: new Map() }
540
+ const cekDe = async (cual, gen) => {
541
+ if (abiertas[cual].has(gen)) return abiertas[cual].get(gen)
542
+ // Un bundle de v4 no traía `gen` en la envoltura: si solo hay una, es esa.
543
+ const wrap = porGen[cual].get(gen) ?? (porGen[cual].size === 1 ? [...porGen[cual].values()][0] : null)
544
+ if (!wrap) return null
545
+ const cek = await openWrap({ wrap, myEncPrivateKey: mine })
546
+ abiertas[cual].set(gen, cek)
547
+ return cek
548
+ }
549
+
550
+ const comprobarFirma = await makeSealCheck(acta, masterPubkey)
551
+
552
+ const out = {}
553
+ for (const [key, e] of Object.entries(sealed.entries || {})) {
554
+ if (e.pub) { out[key] = e.v; continue }
555
+ // `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
556
+ const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
557
+ const gen = e.gen ?? e.e?.gen ?? 0
558
+ await comprobarFirma(e.owner, key, gen, e.e, e.seal)
559
+ const cek = await cekDe(cual, gen)
560
+ if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
561
+ out[key] = await decryptWithCek({ cek, envelope: e.e })
562
+ }
563
+ return out
564
+ }
565
+
381
566
  /**
382
567
  * Escucha los avisos de cambio de configuración de la bóveda.
383
568
  *
@@ -489,8 +674,59 @@ export async function watchSecretsChanges ({
489
674
  try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
490
675
  }
491
676
 
677
+ /**
678
+ * REPARTIR LA LLAVE DE MI CAJÓN a un miembro nuevo (§8.11 del diseño).
679
+ *
680
+ * Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
681
+ * la bóveda no se la puede hacer: envolver exige abrir la llave, y abrirla pide la
682
+ * frase. Este agente SÍ la tiene abierta, así que la reparte él. No gana ningún poder
683
+ * haciéndolo —ya podía leer eso— y por eso es el único que puede hacerlo sin que
684
+ * nadie ceda nada.
685
+ *
686
+ * NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
687
+ * bóveda estuviera comprometida:
688
+ *
689
+ * · la petición va firmada por la MAESTRA;
690
+ * · el acta viaja dentro y se comprueba aparte (también la firma la maestra);
691
+ * · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
692
+ * se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
693
+ * la llave para una pública suya;
694
+ * · y el destinatario tiene que ser de ESTE cajón (`cn === ns`): un servicio no puede
695
+ * ampliar el acceso a nada que no sea lo suyo.
696
+ */
697
+ const handleRewrap = async (payload) => {
698
+ const body = payload?.body
699
+ if (!body || body.op !== 'rewrap') return
700
+ const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
701
+ if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
702
+ if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
703
+ return log('[vault] rewrap request BADLY SIGNED: ignored')
704
+ }
705
+ const acta = body.acta
706
+ if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
707
+ return log('[vault] rewrap request without a valid record: ignored')
708
+ }
709
+ const target = (acta.members || []).find((m) => m.pub === body.target)
710
+ if (!target?.encPub) return log('[vault] rewrap: the target is not in the record (or has no encryption key)')
711
+ if (target.cn !== ns) return log(`[vault] rewrap: ${String(body.target).slice(0, 12)}… is not part of «${ns}»: refused`)
712
+
713
+ try {
714
+ const ident = readServiceIdentity(dir)
715
+ if (!ident?.enc?.privateJwk) return log('[vault] rewrap: this agent has no encryption key')
716
+ const cek = await openWrap({ wrap: body.wrap, myEncPrivateKey: await importDeviceEncKey(ident.enc.privateJwk) })
717
+ const wrap = await wrapForMember({ cek, memberEncPub: target.encPub })
718
+ const data = { op: 'rewrap.ok', owner: body.owner, gen: body.gen, target: body.target, wrap, ts: Date.now() }
719
+ const { signature } = await signWithDevice({ privateJwk: saved.device.privateJwk, data })
720
+ client.sendByPubkey(master, { type: MSG.REWRAP_OK, data, signature, cert: saved.cert })
721
+ log(`[vault] key handed to ${String(body.target).slice(0, 12)}… for ${body.owner} (gen ${body.gen})`)
722
+ } catch (e) {
723
+ log('[vault] rewrap failed: ' + e.message)
724
+ }
725
+ }
726
+
492
727
  const handleMessage = async (payload) => {
493
728
  if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
729
+ if (payload?.type === MSG.REWRAP) return handleRewrap(payload)
494
730
  if (payload?.type !== MSG.SECRETS_CHANGED) return
495
731
  const body = payload.body
496
732
  if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
@@ -646,11 +882,11 @@ function fingerprintOf (secrets) {
646
882
  * servicio no opera hasta que esto resuelva — esa es la regla.
647
883
  * @returns {Promise<Record<string,string>>}
648
884
  */
649
- 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 } = {}) {
650
886
  let delay = retryMs
651
887
  for (;;) {
652
888
  try {
653
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
889
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
654
890
  } catch (e) {
655
891
  // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
656
892
  // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.38.0",
3
+ "version": "0.46.2",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.49.0",
24
- "@dotrino/proxy-client": "^0.10.0",
23
+ "@dotrino/identity": "^0.57.0",
24
+ "@dotrino/proxy-client": "^0.10.1",
25
25
  "ws": "^8.18.0"
26
26
  },
27
27
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "!lib/src/types.d.ts"
44
44
  ],
45
45
  "devDependencies": {
46
- "@dotrino/remote-agent": "^0.3.0",
47
- "typescript": "^5.7.3",
48
- "@types/node": "^22.0.0"
46
+ "@dotrino/remote-agent": "^0.3.2",
47
+ "@types/node": "^22.0.0",
48
+ "typescript": "5.9.3"
49
49
  }
50
50
  }