@dotrino/vault 0.26.0 → 0.28.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 +9 -2
- package/src/admin.js +34 -1
- package/src/atrest.js +0 -0
- package/src/enroll.js +8 -2
- package/src/protocol.js +10 -0
- package/src/service.js +119 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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.
|
|
46
|
+
"@dotrino/identity": ">=0.56.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
|
|
265
|
-
//
|
|
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/protocol.js
CHANGED
|
@@ -66,6 +66,16 @@ export const MSG = Object.freeze({
|
|
|
66
66
|
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
67
67
|
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
68
68
|
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
|
|
69
|
+
// REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
|
|
70
|
+
// bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
|
|
71
|
+
// tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
|
|
72
|
+
//
|
|
73
|
+
// El servicio NO se fía de lo que le manden: comprueba la firma de la maestra, comprueba
|
|
74
|
+
// el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
|
|
75
|
+
// destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
|
|
76
|
+
// llave para alguien que la maestra no haya metido en su propio cajón.
|
|
77
|
+
REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, signature }
|
|
78
|
+
REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
|
|
69
79
|
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
70
80
|
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
71
81
|
// Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
|
package/src/service.js
CHANGED
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
26
|
verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
27
27
|
} from '@dotrino/identity/capabilities'
|
|
28
|
-
import { openWrap, decryptWithCek } from '@dotrino/identity/content'
|
|
28
|
+
import { openWrap, wrapForMember, 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'
|
|
@@ -443,7 +444,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
443
444
|
// · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
|
|
444
445
|
// sin poder abrirlo.
|
|
445
446
|
// Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
|
|
446
|
-
if (payload?.sealed) return openSealedBundle(payload.sealed, saved)
|
|
447
|
+
if (payload?.sealed) return openSealedBundle(payload.sealed, saved, payload.acta, masterPubkey)
|
|
447
448
|
|
|
448
449
|
// Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
|
|
449
450
|
// cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
|
|
@@ -460,21 +461,80 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
460
461
|
* omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
|
|
461
462
|
* con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
|
|
462
463
|
*/
|
|
463
|
-
|
|
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) {
|
|
464
501
|
if (!ident?.enc?.privateJwk) {
|
|
465
502
|
throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
|
|
466
503
|
}
|
|
467
504
|
const mine = await importDeviceEncKey(ident.enc.privateJwk)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
471
526
|
}
|
|
472
527
|
|
|
528
|
+
const comprobarFirma = await makeSealCheck(acta, masterPubkey)
|
|
529
|
+
|
|
473
530
|
const out = {}
|
|
474
531
|
for (const [key, e] of Object.entries(sealed.entries || {})) {
|
|
475
532
|
if (e.pub) { out[key] = e.v; continue }
|
|
476
|
-
// `owner` dice de qué cajón salió, y
|
|
477
|
-
const
|
|
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)
|
|
478
538
|
if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
|
|
479
539
|
out[key] = await decryptWithCek({ cek, envelope: e.e })
|
|
480
540
|
}
|
|
@@ -592,8 +652,59 @@ export async function watchSecretsChanges ({
|
|
|
592
652
|
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
593
653
|
}
|
|
594
654
|
|
|
655
|
+
/**
|
|
656
|
+
* REPARTIR LA LLAVE DE MI CAJÓN a un miembro nuevo (§8.11 del diseño).
|
|
657
|
+
*
|
|
658
|
+
* Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
|
|
659
|
+
* la bóveda no se la puede hacer: envolver exige abrir la llave, y abrirla pide la
|
|
660
|
+
* frase. Este agente SÍ la tiene abierta, así que la reparte él. No gana ningún poder
|
|
661
|
+
* haciéndolo —ya podía leer eso— y por eso es el único que puede hacerlo sin que
|
|
662
|
+
* nadie ceda nada.
|
|
663
|
+
*
|
|
664
|
+
* NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
|
|
665
|
+
* bóveda estuviera comprometida:
|
|
666
|
+
*
|
|
667
|
+
* · la petición va firmada por la MAESTRA;
|
|
668
|
+
* · el acta viaja dentro y se comprueba aparte (también la firma la maestra);
|
|
669
|
+
* · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
|
|
670
|
+
* se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
|
|
671
|
+
* la llave para una pública suya;
|
|
672
|
+
* · y el destinatario tiene que ser de ESTE cajón (`cn === ns`): un servicio no puede
|
|
673
|
+
* ampliar el acceso a nada que no sea lo suyo.
|
|
674
|
+
*/
|
|
675
|
+
const handleRewrap = async (payload) => {
|
|
676
|
+
const body = payload?.body
|
|
677
|
+
if (!body || body.op !== 'rewrap') return
|
|
678
|
+
const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
|
|
679
|
+
if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
|
|
680
|
+
if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
|
|
681
|
+
return log('[vault] rewrap request BADLY SIGNED: ignored')
|
|
682
|
+
}
|
|
683
|
+
const acta = body.acta
|
|
684
|
+
if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
|
|
685
|
+
return log('[vault] rewrap request without a valid record: ignored')
|
|
686
|
+
}
|
|
687
|
+
const target = (acta.members || []).find((m) => m.pub === body.target)
|
|
688
|
+
if (!target?.encPub) return log('[vault] rewrap: the target is not in the record (or has no encryption key)')
|
|
689
|
+
if (target.cn !== ns) return log(`[vault] rewrap: ${String(body.target).slice(0, 12)}… is not part of «${ns}»: refused`)
|
|
690
|
+
|
|
691
|
+
try {
|
|
692
|
+
const ident = readServiceIdentity(dir)
|
|
693
|
+
if (!ident?.enc?.privateJwk) return log('[vault] rewrap: this agent has no encryption key')
|
|
694
|
+
const cek = await openWrap({ wrap: body.wrap, myEncPrivateKey: await importDeviceEncKey(ident.enc.privateJwk) })
|
|
695
|
+
const wrap = await wrapForMember({ cek, memberEncPub: target.encPub })
|
|
696
|
+
const data = { op: 'rewrap.ok', owner: body.owner, gen: body.gen, target: body.target, wrap, ts: Date.now() }
|
|
697
|
+
const { signature } = await signWithDevice({ privateJwk: saved.device.privateJwk, data })
|
|
698
|
+
client.sendByPubkey(master, { type: MSG.REWRAP_OK, data, signature, cert: saved.cert })
|
|
699
|
+
log(`[vault] key handed to ${String(body.target).slice(0, 12)}… for ${body.owner} (gen ${body.gen})`)
|
|
700
|
+
} catch (e) {
|
|
701
|
+
log('[vault] rewrap failed: ' + e.message)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
595
705
|
const handleMessage = async (payload) => {
|
|
596
706
|
if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
|
|
707
|
+
if (payload?.type === MSG.REWRAP) return handleRewrap(payload)
|
|
597
708
|
if (payload?.type !== MSG.SECRETS_CHANGED) return
|
|
598
709
|
const body = payload.body
|
|
599
710
|
if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
|