@dotrino/vault 0.24.0 → 0.26.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.24.0",
4
- "description": "Usa ESTE dispositivo (navegador) como b\u00f3veda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegaci\u00f3n a tus m\u00e1quinas. 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'`).",
3
+ "version": "0.26.0",
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",
7
7
  "module": "src/index.js",
@@ -32,7 +32,8 @@
32
32
  "src",
33
33
  "bin",
34
34
  "README.md",
35
- "LICENSE"
35
+ "LICENSE",
36
+ "!src/types.d.ts"
36
37
  ],
37
38
  "keywords": [
38
39
  "dotrino",
@@ -42,7 +43,7 @@
42
43
  "pairing"
43
44
  ],
44
45
  "peerDependencies": {
45
- "@dotrino/identity": ">=0.30.0",
46
+ "@dotrino/identity": ">=0.53.0",
46
47
  "@dotrino/proxy-client": ">=0.9.0"
47
48
  },
48
49
  "license": "MIT",
@@ -50,12 +51,5 @@
50
51
  "type": "git",
51
52
  "url": "git+https://github.com/imdotrino/dotrino-vault.git",
52
53
  "directory": "lib"
53
- },
54
- "devDependencies": {
55
- "typescript": "^5.7.3",
56
- "@types/node": "^22.0.0"
57
- },
58
- "scripts": {
59
- "type-check": "tsc --noEmit"
60
54
  }
61
55
  }
package/src/atrest.js CHANGED
Binary file
package/src/service.js CHANGED
@@ -22,9 +22,10 @@ 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, decryptWithCek } from '@dotrino/identity/content'
28
29
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
29
30
  import { makeEphemeralKey, openSealed } from './sealed.js'
30
31
  import { parseInvite } from './invite.js'
@@ -277,6 +278,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
277
278
  }
278
279
  try {
279
280
  const device = await makeDeviceKey({ label })
281
+ // La llave de CIFRADO, hermana de la de firma. Sin ella el vault no puede
282
+ // sellarle sus variables a ESTE aparato, y arrancaría sin configuración sin
283
+ // que nadie sepa por qué. Su pública viaja en el enrolamiento y acaba en el
284
+ // acta como `encPub`; la privada no sale de este disco.
285
+ const enc = await makeDeviceEncKey()
280
286
  const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
281
287
  // Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
282
288
  // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
@@ -288,7 +294,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
288
294
  // si falta, asume `join` — pero un agente no debe apoyarse en un default
289
295
  // para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
290
296
  // 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() }
297
+ 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
298
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
293
299
 
294
300
  const enrolled = new Promise((resolve, reject) => {
@@ -314,8 +320,65 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
314
320
 
315
321
  // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
316
322
  // 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 }
323
+ // v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
324
+ // `nodeId` del proxio, y cambiarlo le costaría su cajón de variables.
325
+ 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() })
326
+ return { device, enc, cert: res.cert, iss: qr.iss, replaced }
327
+ } finally { client.close() }
328
+ }
329
+
330
+ /**
331
+ * Se asegura de que este servicio TENGA llave de cifrado, generándola si le falta.
332
+ *
333
+ * Existe para los agentes que se enrolaron ANTES de que la llave existiera: su
334
+ * `service-identity.json` es v1 y solo lleva la de firma. Sin esto, la única forma de
335
+ * conseguirla sería re-enrolarse — y re-enrolar le cambia la pubkey, con lo que pierde
336
+ * su cajón de variables (va indexado por ella) y arrancaría sin configuración, en
337
+ * silencio. Ese es justo el accidente que esta función existe para evitar.
338
+ *
339
+ * La llave de FIRMA no se toca: de ella sale el `nodeId` del proxio.
340
+ *
341
+ * @returns {Promise<{ encPub: string, created: boolean }>}
342
+ */
343
+ export async function ensureEncKey ({ dir } = {}) {
344
+ const saved = readServiceIdentity(dir)
345
+ if (!saved) throw new Error('service not enrolled: run enrollService() first')
346
+ if (saved.enc?.publickey && saved.enc?.privateJwk) return { encPub: saved.enc.publickey, created: false }
347
+ const enc = await makeDeviceEncKey()
348
+ writeServiceIdentity(dir, { ...saved, v: 2, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } })
349
+ return { encPub: enc.encPublickey, created: true }
350
+ }
351
+
352
+ /**
353
+ * Registra en la bóveda la llave de cifrado de ESTE servicio, para que pueda sellarle
354
+ * sus variables. Genera la llave si falta.
355
+ *
356
+ * Va por `MSG.SECRETS` con `op:'enckey'` a propósito: no hace falta una constante nueva
357
+ * del protocolo, y así el trío de archivos vendorizado en el iframe de identidad no se
358
+ * mueve. Registrar una llave no da acceso a nada por sí solo —quien firma esta petición
359
+ * ya tiene la llave de firma del servicio, o sea ya lee ese namespace—, así que no exige
360
+ * la contraseña del perfil.
361
+ */
362
+ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
363
+ const saved = readServiceIdentity(dir)
364
+ const { encPub, created } = await ensureEncKey({ dir })
365
+ ns = ns || saved?.ns
366
+ proxyUrl = proxyUrl || saved?.proxy
367
+ masterPubkey = masterPubkey || saved?.iss
368
+ device = device || saved?.device
369
+ cert = cert || saved?.cert
370
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
371
+
372
+ const client = await freshClient(proxyUrl)
373
+ try {
374
+ await identifyAsService(client, device)
375
+ const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
376
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
377
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
378
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
379
+ const res = await pending
380
+ if (res.type === MSG.ERROR) throw new Error(res.error)
381
+ return { encPub, created, ok: true }
319
382
  } finally { client.close() }
320
383
  }
321
384
 
@@ -373,11 +436,51 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
373
436
  if (!ok) throw new Error('invalid master signature on the secrets reply')
374
437
 
375
438
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
439
+
440
+ // DOS CAPAS DE SOBRE, y hacen cosas distintas:
441
+ // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
442
+ // ni los nombres de tus variables;
443
+ // · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
444
+ // sin poder abrirlo.
445
+ // Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
446
+ if (payload?.sealed) return openSealedBundle(payload.sealed, saved)
447
+
448
+ // Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
449
+ // cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
376
450
  if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
377
451
  return payload.secrets
378
452
  } finally { client.close() }
379
453
  }
380
454
 
455
+ /**
456
+ * Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
457
+ * descifra con ella las variables privadas. Las públicas vienen en claro.
458
+ *
459
+ * Un fallo al abrir es un ERROR DURO, nunca un salto a lo del scope ni un valor
460
+ * omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
461
+ * con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
462
+ */
463
+ async function openSealedBundle (sealed, ident) {
464
+ if (!ident?.enc?.privateJwk) {
465
+ throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
466
+ }
467
+ const mine = await importDeviceEncKey(ident.enc.privateJwk)
468
+ const ceks = {}
469
+ for (const [cual, info] of Object.entries({ ns: sealed.ns, dev: sealed.dev })) {
470
+ if (info?.wrap) ceks[cual] = await openWrap({ wrap: info.wrap, myEncPrivateKey: mine })
471
+ }
472
+
473
+ const out = {}
474
+ for (const [key, e] of Object.entries(sealed.entries || {})) {
475
+ if (e.pub) { out[key] = e.v; continue }
476
+ // `owner` dice de qué cajón salió, y por tanto con qué CEK se abre.
477
+ const cek = ceks[String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns']
478
+ if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
479
+ out[key] = await decryptWithCek({ cek, envelope: e.e })
480
+ }
481
+ return out
482
+ }
483
+
381
484
  /**
382
485
  * Escucha los avisos de cambio de configuración de la bóveda.
383
486
  *
package/src/types.d.ts DELETED
@@ -1,5 +0,0 @@
1
- interface Element { [key: string]: any; }
2
- interface EventTarget { [key: string]: any; }
3
- interface HTMLElement { [key: string]: any; }
4
- interface Event { [key: string]: any; }
5
- interface Window { [key: string]: any; }