@dotrino/vaultd 0.53.0 → 0.58.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.
@@ -23,8 +23,7 @@ import path from 'node:path'
23
23
  import { createHash } from 'node:crypto'
24
24
  import {
25
25
  makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
26
- verifyDeviceSig, makePairingCode, commitCode, pubkeyId
27
- } from '@dotrino/identity/capabilities'
26
+ verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
28
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
29
28
  import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
30
29
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
@@ -298,7 +297,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
298
297
  const res = await enrolled
299
298
 
300
299
  if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
301
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
300
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, ...(expectedScope ? { expectedScope } : {}) })
302
301
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
303
302
  if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
304
303
 
@@ -440,7 +439,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
440
439
  client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
441
440
  const res = await pending
442
441
  if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
443
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
442
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, expectedScope: secretsScope(ns) })
444
443
  if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
445
444
  }
446
445
  } catch (_) { /* la renovación no bloquea el fetch */ }
@@ -458,8 +457,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
458
457
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
459
458
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
460
459
  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')
460
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
463
461
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
464
462
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
465
463
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
@@ -467,14 +465,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
467
465
  if (res.type === MSG.ERROR) throw new Error(res.error)
468
466
  }
469
467
 
470
- // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
468
+ // AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
469
+ // (`sealPub`, buscada por `seq`), que es la misma regla que ya gobierna los sobres.
470
+ // La maestra solo sella el acta y reenvuelve; servir no es cosa suya, y por eso puede
471
+ // quedarse cerrada.
471
472
  const body = res.body
472
473
  if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
473
474
  if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
474
- const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
475
- if (!ok) throw new Error('invalid master signature on the secrets reply')
476
475
 
476
+ // Se abre ANTES de comprobar la firma, y no es un descuido: el sobre de fuera va
477
+ // sellado a la efímera que acabamos de estrenar, así que abrirlo no prueba nada ni
478
+ // confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
479
+ // dentro se usa hasta después de verificar.
477
480
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
481
+ await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
478
482
 
479
483
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
480
484
  // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
@@ -499,6 +503,40 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
499
503
  * omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
500
504
  * con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
501
505
  */
506
+ /**
507
+ * ¿QUIÉN CONTESTÓ, Y PUEDE? La misma regla que gobierna los sobres, aplicada al
508
+ * transporte: **el acta dice qué llave firma qué**.
509
+ *
510
+ * La maestra no entra aquí. Sus dos trabajos son sellar el acta y reenvolver los sobres
511
+ * de todos los aparatos; servir una petición no es ninguno de los dos, así que exigir su
512
+ * firma la obligaba a estar despierta todo el tiempo — y de paso hacía imposible que
513
+ * sirviera nadie más.
514
+ *
515
+ * Lo que se comprueba, en orden:
516
+ * 1. el acta que llega va firmada por la maestra que este agente lleva pineada desde
517
+ * que se enroló (o por la que aquella nombró al traspasar);
518
+ * 2. la firma del cuerpo cuadra con la llave de sellado que ESA acta nombra para el
519
+ * `seq` con el que se firmó.
520
+ *
521
+ * Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
522
+ * quisiera.
523
+ */
524
+ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
525
+ if (!seal?.sig || typeof seal.seq !== 'number') {
526
+ throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
527
+ }
528
+ if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
529
+ if (acta.sealedBy !== masterPubkey) {
530
+ throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
531
+ }
532
+ if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
533
+ const pub = sealKeyAt(acta, seal.seq)
534
+ if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
535
+ if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
536
+ throw new Error('the reply signature does not check out against the key the record names')
537
+ }
538
+ }
539
+
502
540
  /**
503
541
  * ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
504
542
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.53.0",
3
+ "version": "0.58.0",
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.61.0",
24
- "@dotrino/passmanager": "^0.1.0",
23
+ "@dotrino/identity": "^0.64.0",
24
+ "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
27
27
  },
@@ -44,7 +44,7 @@
44
44
  "!lib/src/types.d.ts"
45
45
  ],
46
46
  "devDependencies": {
47
- "@dotrino/remote-agent": "^0.3.2",
47
+ "@dotrino/remote-agent": "^0.5.3",
48
48
  "@types/node": "^22.0.0",
49
49
  "typescript": "5.9.3"
50
50
  }
package/src/client.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
12
12
  * guardarlo. La maestra nunca sale del vault.
13
13
  */
14
- import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
14
+ import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
15
15
  import { installNodeGlobals } from './node-globals.js'
16
16
  import { MSG } from './protocol.js'
17
17
 
@@ -84,7 +84,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
84
84
  const res = await enrolled
85
85
 
86
86
  // VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
87
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
87
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
88
88
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
89
89
  if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
90
90
  if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
@@ -151,7 +151,7 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
151
151
  if (res.type === MSG.ERROR) throw new Error(res.error)
152
152
  // Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
153
153
  // firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
154
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
154
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
155
155
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
156
156
  if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
157
157
  return { cert: res.cert }