@dotrino/vault 0.53.0 → 0.54.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.53.0",
3
+ "version": "0.54.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",
package/src/enroll.js CHANGED
@@ -52,7 +52,18 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
55
+ const SCOPE_TO_CAP = {
56
+ 'vault:sign': 'sign',
57
+ 'vault:store': 'store',
58
+ 'vault:read': 'read',
59
+ 'vault:admin': 'admin',
60
+ 'vault:passwords': 'passwords',
61
+ // `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
62
+ // hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
63
+ // nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
64
+ // donde obligar a un segundo paso a mano es el paso que nadie da.
65
+ 'vault:replica': 'replica'
66
+ }
56
67
  export const scopeToCaps = (scope) =>
57
68
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
69
 
package/src/service.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
26
26
  verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
28
- import { verifyActa, sealKeyAt, sealersOf } from '@dotrino/identity/acta'
28
+ import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
29
29
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
30
30
  import { makeEphemeralKey, openSealed } from './sealed.js'
31
31
  import { parseInvite } from './invite.js'
@@ -241,7 +241,17 @@ function writeServiceIdentity (dir, obj) {
241
241
  * @param {number} [opts.approveTimeoutMs]
242
242
  * @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
243
243
  */
244
- export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000 } = {}) {
244
+ /**
245
+ * @param {boolean} [opts.withEncKey] Estrenar llave de CIFRADO y registrarla en el acta.
246
+ * Por defecto sí: sin ella el aparato entra pero no le llega ningún secreto.
247
+ *
248
+ * Un REPLICADOR entra con `false`, y no es una optimización: es su garantía. Regla del
249
+ * dueño (2026-09-02), *«recibirá todos los sobres que se generen, ningún sobre firmado
250
+ * para él»*. Sin `encPub` en el acta no hay a dónde envolverle nada, así que repartir no
251
+ * puede convertirse en leer ni por descuido ni por un cambio futuro. Es el mismo cerrojo
252
+ * que tiene la llave de comunicación.
253
+ */
254
+ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
245
255
  // `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
246
256
  // `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
247
257
  // invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
@@ -271,14 +281,15 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
271
281
  try {
272
282
  const device = await makeDeviceKey({ label })
273
283
  // La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
274
- // aparato entra al acta pero no le llega ningún secreto, y no da error.
275
- const enc = await makeDeviceEncKey()
284
+ // aparato entra al acta pero no le llega ningún secreto, y no da error. Un replicador
285
+ // entra así A PROPÓSITO (ver `withEncKey`): reparte sobres cerrados y no abre ninguno.
286
+ const enc = withEncKey ? await makeDeviceEncKey() : null
276
287
  const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
277
288
  // Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
278
289
  // aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
279
290
  const code = makePairingCode()
280
291
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
281
- 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
+ const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc?.encPublickey || null, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
282
293
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
283
294
 
284
295
  const enrolled = new Promise((resolve, reject) => {
@@ -313,7 +324,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
313
324
  })
314
325
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
315
326
 
316
- return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
327
+ return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
317
328
  } finally { client.close() }
318
329
  }
319
330
 
@@ -459,10 +470,23 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
459
470
  saved = { ...saved, ...patch }
460
471
  if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
461
472
  }
462
- /** Hasta qué acta hemos visto: es lo que dispara la renovación la próxima vez. */
473
+ /**
474
+ * Hasta qué acta hemos visto. Dispara la renovación la próxima vez, y desde que hay
475
+ * replicadores es además el freno anti-rollback (`verifyResponder`).
476
+ *
477
+ * SOLO SUBE. Antes escribía cualquier `seq` que llegara, también uno MENOR: bastaba que
478
+ * alguien contestara con un acta vieja para que el pin bajara y dejara de proteger de
479
+ * ahí en adelante. Un pin que retrocede no es un pin — se llama `maxSeq` en el diseño
480
+ * justo por esto (`acta-de-perfil.md` §2.3).
481
+ */
463
482
  const anotarActa = (acta) => {
464
- if (typeof acta?.seq !== 'number' || saved?.actaSeq === acta.seq) return
465
- recordar({ actaSeq: acta.seq })
483
+ if (typeof acta?.seq !== 'number') return
484
+ if (typeof saved?.actaSeq === 'number' && acta.seq <= saved.actaSeq) return
485
+ // Y QUIÉNES REPARTEN. Sale del acta, que llega firmada en cada respuesta, así que no
486
+ // hay una lista que mantener a mano ni un `.env` que se olvide: darle `replica` a un
487
+ // aparato basta para que sus servicios sepan preguntarle cuando la bóveda no esté.
488
+ const replicas = (acta.members || []).filter((m) => memberCan(acta, m.pub, 'replica')).map((m) => m.pub)
489
+ recordar({ actaSeq: acta.seq, replicas })
466
490
  }
467
491
 
468
492
  // DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
@@ -503,16 +527,35 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
503
527
  // poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
504
528
  const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
505
529
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
506
- const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
507
- client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
508
- let res = await pending
530
+ // A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
531
+ // que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
532
+ // lleva una lista de bóvedas y las prueba en orden»).
533
+ //
534
+ // Sin esto, un replicador no servía absolutamente para nada: el mensaje se mandaba a
535
+ // la llave de la bóveda y, con la bóveda apagada, no llegaba a ninguna parte. Lo
536
+ // destapó el smoke con las dos máquinas encendidas y la bóveda muerta, que es
537
+ // exactamente el caso que un replicador existe para cubrir.
538
+ //
539
+ // La bóveda va PRIMERO siempre: un replicador solo puede estar igual de al día o menos.
540
+ const destinos = [masterPubkey, ...(saved?.replicas || []).filter((k) => k !== masterPubkey)]
541
+ // El plazo se reparte: no tiene sentido esperar el timeout entero a una bóveda apagada
542
+ // y quedarse sin tiempo para preguntarle a quien sí está.
543
+ const porDestino = Math.max(4000, Math.round(timeoutMs / destinos.length))
544
+ let res = null
545
+ let ultimo = null
546
+ for (const destino of destinos) {
547
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, porDestino)
548
+ client.sendByPubkey(destino, { type: MSG.SECRETS, data, signature, cert })
549
+ try { res = await pending; break } catch (e) { ultimo = e }
550
+ }
551
+ if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
509
552
  if (res.type === MSG.ERROR) throw new Error(res.error)
510
553
 
511
554
  // CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
512
555
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
513
556
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
514
557
  if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
515
- await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
558
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
516
559
  anotarActa(res.acta)
517
560
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
518
561
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
@@ -534,7 +577,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
534
577
  // confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
535
578
  // dentro se usa hasta después de verificar.
536
579
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
537
- await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
580
+ await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
538
581
  anotarActa(payload?.acta)
539
582
 
540
583
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
@@ -578,8 +621,32 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
578
621
  * Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
579
622
  * quisiera.
580
623
  */
581
- export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
582
- if (!seal?.sig || typeof seal.seq !== 'number') {
624
+ /**
625
+ * ¿QUIÉN PUEDE HABERME CONTESTADO ESTO? Hay dos respuestas legítimas y una condición.
626
+ *
627
+ * · **La bóveda.** Firma con la llave de sellado que el acta nombra para ese `seq`. Es
628
+ * el camino de siempre y no cambia.
629
+ * · **Un REPLICADOR** (`seal.by`). No tiene esa llave —no tiene maestra— así que firma
630
+ * con la suya de aparato, y lo que la autoriza es que el acta le reconozca `replica`.
631
+ * Reparte lo que la bóveda ya selló: los sobres de dentro se siguen comprobando contra
632
+ * la llave de sellado (`makeSealCheck`), así que un replicador no puede inventarse
633
+ * contenido, solo entregarlo.
634
+ *
635
+ * LA CONDICIÓN, y es la que cierra el agujero que `replicas.md` §6.1 llama «construir el
636
+ * ataque»: **un replicador solo le contesta a quien YA conoce la cuenta.** Un replicador
637
+ * atrasado presenta un acta donde un aparato revocado sigue siendo miembro; contra eso, a
638
+ * quien tiene `maxSeq` pineado lo protege el pin, pero a un aparato que llega nuevo no hay
639
+ * con qué compararlo. Así que sin pin no se le cree: que conteste la bóveda.
640
+ *
641
+ * El precio, dicho en voz alta: un aparato nuevo necesita la bóveda encendida una vez. El
642
+ * oráculo de frescura (§2.4.2 del acta) es lo que quitaría ese precio; mientras no exista,
643
+ * esto es el freno, y es un freno de verdad y no un aviso.
644
+ *
645
+ * @param {number|null} [knownSeq] El `seq` más alto que este agente ya vio de esta cuenta.
646
+ * `null`/ausente = no conoce la cuenta, y entonces un replicador no vale.
647
+ */
648
+ export async function verifyResponder ({ body, seal, acta, masterPubkey, knownSeq = null }) {
649
+ if (!seal?.sig) {
583
650
  throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
584
651
  }
585
652
  if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
@@ -587,6 +654,33 @@ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
587
654
  throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
588
655
  }
589
656
  if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
657
+
658
+ // NUNCA HACIA ATRÁS. Vale para los dos caminos: un acta con `seq` menor del que ya
659
+ // vimos es un rollback, venga de donde venga.
660
+ if (typeof knownSeq === 'number' && typeof acta.seq === 'number' && acta.seq < knownSeq) {
661
+ throw Object.assign(
662
+ new Error(`the record went backwards (#${acta.seq} after #${knownSeq}): refusing the reply`),
663
+ { code: 'stale-record' })
664
+ }
665
+
666
+ if (seal.by) {
667
+ if (typeof knownSeq !== 'number') {
668
+ throw Object.assign(
669
+ new Error('a replica answered, and this device has never seen the account: ask the vault itself the first time'),
670
+ { code: 'replica-unknown-account' })
671
+ }
672
+ if (!memberCan(acta, seal.by, 'replica')) {
673
+ throw new Error('the reply comes from a device the record does not allow to serve as a replica')
674
+ }
675
+ if (!(await verifyDeviceSig({ publickey: seal.by, data: body, signature: seal.sig }))) {
676
+ throw new Error('the replica signature does not check out')
677
+ }
678
+ return
679
+ }
680
+
681
+ if (typeof seal.seq !== 'number') {
682
+ throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
683
+ }
590
684
  const pub = sealKeyAt(acta, seal.seq)
591
685
  if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
592
686
  if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {