@dotrino/vault 0.25.0 → 0.27.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.25.0",
3
+ "version": "0.27.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",
@@ -43,7 +43,7 @@
43
43
  "pairing"
44
44
  ],
45
45
  "peerDependencies": {
46
- "@dotrino/identity": ">=0.53.0",
46
+ "@dotrino/identity": ">=0.54.0",
47
47
  "@dotrino/proxy-client": ">=0.9.0"
48
48
  },
49
49
  "license": "MIT",
@@ -51,5 +51,12 @@
51
51
  "type": "git",
52
52
  "url": "git+https://github.com/imdotrino/dotrino-vault.git",
53
53
  "directory": "lib"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.0.0",
57
+ "typescript": "5.9.3"
58
+ },
59
+ "scripts": {
60
+ "type-check": "tsc --noEmit"
54
61
  }
55
62
  }
package/src/admin.js CHANGED
@@ -41,7 +41,12 @@ 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'
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'
45
50
  ])
46
51
 
47
52
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
@@ -212,6 +217,34 @@ export function createAdminDesk ({
212
217
  return { ok: true, result: result || { ok: true } }
213
218
  }
214
219
 
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
+
215
248
  // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
216
249
  // todos los certificados. Las dos cosas o ninguna.
217
250
  //
package/src/atrest.js CHANGED
Binary file
package/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) {
package/src/service.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  verifyDeviceSig, makePairingCode, commitCode, pubkeyId
27
27
  } from '@dotrino/identity/capabilities'
28
28
  import { openWrap, decryptWithCek } from '@dotrino/identity/content'
29
+ import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
29
30
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
30
31
  import { makeEphemeralKey, openSealed } from './sealed.js'
31
32
  import { parseInvite } from './invite.js'
@@ -327,6 +328,61 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
327
328
  } finally { client.close() }
328
329
  }
329
330
 
331
+ /**
332
+ * Se asegura de que este servicio TENGA llave de cifrado, generándola si le falta.
333
+ *
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.
339
+ *
340
+ * La llave de FIRMA no se toca: de ella sale el `nodeId` del proxio.
341
+ *
342
+ * @returns {Promise<{ encPub: string, created: boolean }>}
343
+ */
344
+ export async function ensureEncKey ({ dir } = {}) {
345
+ const saved = readServiceIdentity(dir)
346
+ if (!saved) throw new Error('service not enrolled: run enrollService() first')
347
+ if (saved.enc?.publickey && saved.enc?.privateJwk) return { encPub: saved.enc.publickey, created: false }
348
+ const enc = await makeDeviceEncKey()
349
+ writeServiceIdentity(dir, { ...saved, v: 2, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } })
350
+ return { encPub: enc.encPublickey, created: true }
351
+ }
352
+
353
+ /**
354
+ * Registra en la bóveda la llave de cifrado de ESTE servicio, para que pueda sellarle
355
+ * sus variables. Genera la llave si falta.
356
+ *
357
+ * Va por `MSG.SECRETS` con `op:'enckey'` a propósito: no hace falta una constante nueva
358
+ * del protocolo, y así el trío de archivos vendorizado en el iframe de identidad no se
359
+ * mueve. Registrar una llave no da acceso a nada por sí solo —quien firma esta petición
360
+ * ya tiene la llave de firma del servicio, o sea ya lee ese namespace—, así que no exige
361
+ * la contraseña del perfil.
362
+ */
363
+ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
364
+ const saved = readServiceIdentity(dir)
365
+ const { encPub, created } = await ensureEncKey({ dir })
366
+ ns = ns || saved?.ns
367
+ proxyUrl = proxyUrl || saved?.proxy
368
+ masterPubkey = masterPubkey || saved?.iss
369
+ device = device || saved?.device
370
+ cert = cert || saved?.cert
371
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
372
+
373
+ const client = await freshClient(proxyUrl)
374
+ try {
375
+ await identifyAsService(client, device)
376
+ const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
377
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
378
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
379
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
380
+ const res = await pending
381
+ if (res.type === MSG.ERROR) throw new Error(res.error)
382
+ return { encPub, created, ok: true }
383
+ } finally { client.close() }
384
+ }
385
+
330
386
  /**
331
387
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
332
388
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
@@ -388,7 +444,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
388
444
  // · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
389
445
  // sin poder abrirlo.
390
446
  // Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
391
- if (payload?.sealed) return openSealedBundle(payload.sealed, saved)
447
+ if (payload?.sealed) return openSealedBundle(payload.sealed, saved, payload.acta, masterPubkey)
392
448
 
393
449
  // Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
394
450
  // cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
@@ -405,21 +461,80 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
405
461
  * omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
406
462
  * con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
407
463
  */
408
- async function openSealedBundle (sealed, ident) {
464
+ /**
465
+ * ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
466
+ *
467
+ * Envolver una llave solo necesita públicas, así que **cualquiera puede fabricar un sobre
468
+ * válido** para este servicio: abrirlo prueba que es para mí, no que lo escribió quien
469
+ * debía. Lo que lo prueba es la firma, hecha con la llave de sellado que el acta nombra
470
+ * para el `seq` con el que se firmó — y el acta la firma la maestra, que es la que este
471
+ * agente lleva pineada desde que se enroló.
472
+ *
473
+ * Una firma que NO cuadra es un error duro: es exactamente el caso que esto viene a
474
+ * cazar. Un sobre SIN firma se acepta y se avisa: los hay de antes de que esto existiera
475
+ * y negarse a arrancar por eso apagaría servicios que llevan meses bien.
476
+ */
477
+ export async function makeSealCheck (acta, masterPubkey, log = console.log) {
478
+ if (!acta) return () => {}
479
+ // El acta tiene que venir firmada por la maestra que este agente ya conoce. Si la
480
+ // selló otro (un traspaso que este agente no ha visto), no se puede establecer
481
+ // procedencia: se dice y se sigue, en vez de fingir que se comprobó.
482
+ const ok = acta.sealedBy === masterPubkey && (await verifyActa({ acta })).ok
483
+ if (!ok) {
484
+ log('[vault] ⚠ the record does not come from the master this agent knows: envelope provenance NOT checked')
485
+ return () => {}
486
+ }
487
+ let avisado = false
488
+ return async (owner, key, gen, e, seal) => {
489
+ if (!seal?.sig) {
490
+ if (!avisado) { avisado = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
491
+ return
492
+ }
493
+ const pub = sealKeyAt(acta, seal.seq)
494
+ if (!pub) throw new Error(`${key}: the record has no sealing key for #${seal.seq} (the envelope claims a record that does not exist)`)
495
+ const good = await verifyDeviceSig({ publickey: pub, data: { owner, key, gen, iv: e.iv, ct: e.ct }, signature: seal.sig })
496
+ if (!good) throw new Error(`${key}: the envelope signature does not check out — it did not come from this vault`)
497
+ }
498
+ }
499
+
500
+ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null) {
409
501
  if (!ident?.enc?.privateJwk) {
410
502
  throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
411
503
  }
412
504
  const mine = await importDeviceEncKey(ident.enc.privateJwk)
413
- const ceks = {}
414
- for (const [cual, info] of Object.entries({ ns: sealed.ns, dev: sealed.dev })) {
415
- if (info?.wrap) ceks[cual] = await openWrap({ wrap: info.wrap, myEncPrivateKey: mine })
505
+
506
+ // UNA LLAVE POR GENERACIÓN, no una por cajón. Desde v5 cada escritura estrena
507
+ // generación —la bóveda no puede reutilizar una llave que no puede abrir—, así que dos
508
+ // variables del mismo cajón pueden venir de generaciones distintas. El bundle trae
509
+ // TODAS las envolturas de este aparato; se abren perezosamente, solo las que hagan
510
+ // falta. `sealed.ns`/`sealed.dev` (una sola, la vigente) siguen entrando: es el bundle
511
+ // de v4 y sirve para lo que ese vault selló.
512
+ const porGen = { ns: new Map(), dev: new Map() }
513
+ const añade = (cual, info) => { if (info?.wrap) porGen[cual].set(info.gen ?? 0, info.wrap) }
514
+ añade('ns', sealed.ns); añade('dev', sealed.dev)
515
+ for (const cual of ['ns', 'dev']) for (const info of sealed.wraps?.[cual] || []) añade(cual, info)
516
+
517
+ const abiertas = { ns: new Map(), dev: new Map() }
518
+ const cekDe = async (cual, gen) => {
519
+ if (abiertas[cual].has(gen)) return abiertas[cual].get(gen)
520
+ // Un bundle de v4 no traía `gen` en la envoltura: si solo hay una, es esa.
521
+ const wrap = porGen[cual].get(gen) ?? (porGen[cual].size === 1 ? [...porGen[cual].values()][0] : null)
522
+ if (!wrap) return null
523
+ const cek = await openWrap({ wrap, myEncPrivateKey: mine })
524
+ abiertas[cual].set(gen, cek)
525
+ return cek
416
526
  }
417
527
 
528
+ const comprobarFirma = await makeSealCheck(acta, masterPubkey)
529
+
418
530
  const out = {}
419
531
  for (const [key, e] of Object.entries(sealed.entries || {})) {
420
532
  if (e.pub) { out[key] = e.v; continue }
421
- // `owner` dice de qué cajón salió, y por tanto con qué CEK se abre.
422
- const cek = ceks[String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns']
533
+ // `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
534
+ const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
535
+ const gen = e.gen ?? e.e?.gen ?? 0
536
+ await comprobarFirma(e.owner, key, gen, e.e, e.seal)
537
+ const cek = await cekDe(cual, gen)
423
538
  if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
424
539
  out[key] = await decryptWithCek({ cek, envelope: e.e })
425
540
  }