@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 +5 -11
- package/src/atrest.js +0 -0
- package/src/service.js +108 -5
- package/src/types.d.ts +0 -5
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Usa ESTE dispositivo (navegador) como
|
|
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.
|
|
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,
|
|
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
|
-
|
|
318
|
-
|
|
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
|
*
|