@dotrino/vaultd 0.38.0 → 0.46.2
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/README.md +4 -1
- package/lib/src/admin.js +4 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/enroll.js +12 -3
- package/lib/src/protocol.js +10 -0
- package/lib/src/service.js +288 -52
- package/package.json +6 -6
- package/src/ctl.js +273 -17
- package/src/daemon.js +161 -14
- package/src/manager.js +6 -0
- package/src/profiles.js +82 -9
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +777 -91
- package/src/store.js +3 -1
- package/src/tui/app.js +119 -9
- package/src/tui/i18n.js +31 -6
- package/src/vault.js +647 -30
- package/src/vaultControl.js +50 -14
package/README.md
CHANGED
|
@@ -239,6 +239,9 @@ dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma
|
|
|
239
239
|
dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
|
|
240
240
|
dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
241
241
|
dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
242
|
+
dotrino-vault pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
|
|
243
|
+
# Se combina con --service: `--service eco --scope sign` = un bot que firma
|
|
244
|
+
# como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
|
|
242
245
|
dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
|
|
243
246
|
# aparatos que sirven ese namespace
|
|
244
247
|
dotrino-vault secret set <ns> CLAVE=valor CLAVE2=valor2 … # VARIAS de una vez (un solo aviso)
|
|
@@ -313,7 +316,7 @@ dos.
|
|
|
313
316
|
```sh
|
|
314
317
|
dotrino-vault secret set web PUBLIC_URL https://ejemplo.com --public
|
|
315
318
|
dotrino-vault secret set web API_KEY sk-… # sin bandera: privada
|
|
316
|
-
dotrino-vault secret visibility web PUBLIC_URL private #
|
|
319
|
+
dotrino-vault secret visibility web PUBLIC_URL private # taparla sin tocar el valor (privada → pública no existe)
|
|
317
320
|
```
|
|
318
321
|
|
|
319
322
|
- **Se nace privada.** Y **rotar el valor conserva la visibilidad**: exponer un secreto
|
package/lib/src/admin.js
CHANGED
|
@@ -41,6 +41,10 @@ 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
|
+
// Ver un valor, su histórico o volver a una versión NO están, y es a propósito: un
|
|
45
|
+
// aparato que administra no tiene sobres de lo privado (solo el servicio dueño y la
|
|
46
|
+
// recuperación). Eso lo hace la bóveda en su máquina (`secret show/history/revert`).
|
|
47
|
+
// No lo cablees.
|
|
44
48
|
'vars', 'var.set', 'var.setMany'
|
|
45
49
|
])
|
|
46
50
|
|
package/lib/src/atrest.js
CHANGED
|
Binary file
|
package/lib/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) {
|
|
@@ -335,8 +341,11 @@ export function createEnrollDesk ({
|
|
|
335
341
|
let record = null
|
|
336
342
|
try {
|
|
337
343
|
if (typeof identity.admitMember === 'function') {
|
|
344
|
+
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
345
|
+
// cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
|
|
346
|
+
// con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
|
|
338
347
|
const cn = scopeToCn(pend.scope)
|
|
339
|
-
const caps = cn ? ['secrets'] :
|
|
348
|
+
const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
|
|
340
349
|
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
341
350
|
}
|
|
342
351
|
record = (await identity.profileActa?.())?.acta || null
|
package/lib/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/lib/src/service.js
CHANGED
|
@@ -22,9 +22,11 @@ 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, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
29
|
+
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
28
30
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
29
31
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
30
32
|
import { parseInvite } from './invite.js'
|
|
@@ -222,47 +224,37 @@ function writeServiceIdentity (dir, obj) {
|
|
|
222
224
|
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
223
225
|
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
224
226
|
*/
|
|
225
|
-
|
|
227
|
+
/**
|
|
228
|
+
* EL ENROLAMIENTO, a secas: con una invitación de la bóveda (en cualquiera de sus
|
|
229
|
+
* formas) crea las DOS llaves del aparato —firma y cifrado—, pide el cert y lo
|
|
230
|
+
* verifica. **No persiste nada**: devuelve la identidad y quien llama decide dónde
|
|
231
|
+
* vive (`enrollService` la guarda como servicio; `@dotrino/remote-agent` como
|
|
232
|
+
* `link.json`). Es el único sitio del ecosistema donde se enrola un agente headless:
|
|
233
|
+
* si falta algo al enrolar, se añade aquí y lo heredan todos.
|
|
234
|
+
*
|
|
235
|
+
* @param {Object} opts
|
|
236
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
237
|
+
* @param {string} [opts.label]
|
|
238
|
+
* @param {string|null} [opts.expectedScope] Scope que el cert DEBE traer (null = no se exige).
|
|
239
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
240
|
+
* @param {number} [opts.approveTimeoutMs]
|
|
241
|
+
* @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
|
|
242
|
+
*/
|
|
243
|
+
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000 } = {}) {
|
|
226
244
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
227
|
-
// `dotrino-vault pair
|
|
228
|
-
//
|
|
229
|
-
// «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
|
|
230
|
-
// este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
|
|
231
|
-
// cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
|
|
232
|
-
// incluida la vieja, así que esto entiende cualquier invitación.
|
|
245
|
+
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
246
|
+
// invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
|
|
233
247
|
if (typeof qr === 'string') {
|
|
234
248
|
const o = parseInvite(qr)
|
|
235
|
-
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair
|
|
249
|
+
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair`)')
|
|
236
250
|
qr = o
|
|
237
251
|
}
|
|
238
252
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
|
|
239
253
|
rejectAdoption(qr.m)
|
|
240
|
-
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
241
|
-
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
242
|
-
label = label || 'service:' + ns
|
|
243
|
-
|
|
244
|
-
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
245
|
-
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
246
|
-
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
247
|
-
// que se re-pineen a mano.
|
|
248
|
-
const previous = readServiceIdentity(dir)
|
|
249
|
-
let replaced = null
|
|
250
|
-
if (previous?.device?.publickey) {
|
|
251
|
-
replaced = {
|
|
252
|
-
ns: previous.ns,
|
|
253
|
-
enrolledAt: previous.enrolledAt,
|
|
254
|
-
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
255
|
-
}
|
|
256
|
-
try { onReplace?.(replaced) } catch (_) {}
|
|
257
|
-
}
|
|
258
254
|
|
|
259
255
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
260
256
|
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
261
257
|
if (!qr.iss) {
|
|
262
|
-
// `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
|
|
263
|
-
// canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
|
|
264
|
-
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
265
|
-
// aunque la bóveda esté en otro proxio de la malla.
|
|
266
258
|
const target = await resolveAppointment(client, qr.conn)
|
|
267
259
|
const hello = await new Promise((resolve, reject) => {
|
|
268
260
|
const off = client.on('message', (_f, p) => {
|
|
@@ -277,25 +269,22 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
277
269
|
}
|
|
278
270
|
try {
|
|
279
271
|
const device = await makeDeviceKey({ label })
|
|
272
|
+
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
273
|
+
// aparato entra al acta pero no le llega ningún secreto, y no da error.
|
|
274
|
+
const enc = await makeDeviceEncKey()
|
|
280
275
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
281
|
-
// Código ALEATORIO
|
|
282
|
-
//
|
|
276
|
+
// Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
|
|
277
|
+
// aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
|
|
283
278
|
const code = makePairingCode()
|
|
284
|
-
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
285
|
-
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
286
279
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
287
|
-
|
|
288
|
-
// si falta, asume `join` — pero un agente no debe apoyarse en un default
|
|
289
|
-
// para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
|
|
290
|
-
// 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() }
|
|
280
|
+
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
281
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
293
282
|
|
|
294
283
|
const enrolled = new Promise((resolve, reject) => {
|
|
295
284
|
const off = client.on('message', (_from, p) => {
|
|
296
285
|
if (!p || typeof p !== 'object') return
|
|
297
286
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
298
|
-
const show = onCode || (({ deviceId, code }) => console.log(`[vault
|
|
287
|
+
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
299
288
|
show({ deviceId, code })
|
|
300
289
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
301
290
|
})
|
|
@@ -305,17 +294,110 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
305
294
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
306
295
|
const res = await enrolled
|
|
307
296
|
|
|
308
|
-
// Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
|
|
309
|
-
// para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
|
|
310
297
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
311
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope:
|
|
298
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
312
299
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
313
300
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
314
301
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
302
|
+
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
303
|
+
} finally { client.close() }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Enrola ESTE servicio contra el vault y persiste su identidad.
|
|
308
|
+
*
|
|
309
|
+
* UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
|
|
310
|
+
* aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
|
|
311
|
+
* adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
|
|
312
|
+
* el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
|
|
313
|
+
* había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
|
|
314
|
+
* es un servicio, no una persona; no tiene por qué "ser varios".
|
|
315
|
+
*
|
|
316
|
+
* Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
|
|
317
|
+
* es además la forma de rotar la identidad de un agente comprometido. Lo que sí
|
|
318
|
+
* hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
|
|
319
|
+
*
|
|
320
|
+
* En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
|
|
321
|
+
* que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
|
|
322
|
+
* Muestra un código por `onCode`: el dueño lo tipea en el vault
|
|
323
|
+
* (`dotrino-vault approve <código>`).
|
|
324
|
+
*
|
|
325
|
+
* @param {Object} opts
|
|
326
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
327
|
+
* @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
|
|
328
|
+
* @param {string} opts.dir Dónde persistir `service-identity.json`.
|
|
329
|
+
* @param {string} [opts.label]
|
|
330
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
331
|
+
* @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
|
|
332
|
+
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
333
|
+
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
334
|
+
*/
|
|
335
|
+
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
336
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
337
|
+
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
338
|
+
label = label || 'service:' + ns
|
|
339
|
+
|
|
340
|
+
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
341
|
+
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
342
|
+
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
343
|
+
// que se re-pineen a mano.
|
|
344
|
+
const previous = readServiceIdentity(dir)
|
|
345
|
+
let replaced = null
|
|
346
|
+
if (previous?.device?.publickey) {
|
|
347
|
+
replaced = {
|
|
348
|
+
ns: previous.ns,
|
|
349
|
+
enrolledAt: previous.enrolledAt,
|
|
350
|
+
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
351
|
+
}
|
|
352
|
+
try { onReplace?.(replaced) } catch (_) {}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { device, enc, cert, iss, proxy } = await enrollWithVault({ qr, label, expectedScope: secretsScope(ns), onCode, approveTimeoutMs })
|
|
356
|
+
// v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
|
|
357
|
+
// id de nodo del proxio y la fila del acta.
|
|
358
|
+
writeServiceIdentity(dir, { v: 2, ns, iss, proxy, device, enc, cert, enrolledAt: Date.now() })
|
|
359
|
+
return { device, enc, cert, iss, replaced }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function ensureEncKey ({ dir } = {}) {
|
|
363
|
+
const saved = readServiceIdentity(dir)
|
|
364
|
+
if (!saved) throw new Error('service not enrolled: run enrollService() first')
|
|
365
|
+
if (saved.enc?.publickey && saved.enc?.privateJwk) return { encPub: saved.enc.publickey, created: false }
|
|
366
|
+
const enc = await makeDeviceEncKey()
|
|
367
|
+
writeServiceIdentity(dir, { ...saved, v: 2, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } })
|
|
368
|
+
return { encPub: enc.encPublickey, created: true }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Registra en la bóveda la llave de cifrado de ESTE servicio, para que pueda sellarle
|
|
373
|
+
* sus variables. Genera la llave si falta.
|
|
374
|
+
*
|
|
375
|
+
* Va por `MSG.SECRETS` con `op:'enckey'` a propósito: no hace falta una constante nueva
|
|
376
|
+
* del protocolo, y así el trío de archivos vendorizado en el iframe de identidad no se
|
|
377
|
+
* mueve. Registrar una llave no da acceso a nada por sí solo —quien firma esta petición
|
|
378
|
+
* ya tiene la llave de firma del servicio, o sea ya lee ese namespace—, así que no exige
|
|
379
|
+
* la contraseña del perfil.
|
|
380
|
+
*/
|
|
381
|
+
export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
382
|
+
const saved = readServiceIdentity(dir)
|
|
383
|
+
const { encPub, created } = await ensureEncKey({ dir })
|
|
384
|
+
ns = ns || saved?.ns
|
|
385
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
386
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
387
|
+
device = device || saved?.device
|
|
388
|
+
cert = cert || saved?.cert
|
|
389
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
390
|
+
|
|
391
|
+
const client = await freshClient(proxyUrl)
|
|
392
|
+
try {
|
|
393
|
+
await identifyAsService(client, device)
|
|
394
|
+
const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
|
|
395
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
396
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
397
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
398
|
+
const res = await pending
|
|
399
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
400
|
+
return { encPub, created, ok: true }
|
|
319
401
|
} finally { client.close() }
|
|
320
402
|
}
|
|
321
403
|
|
|
@@ -325,9 +407,13 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
325
407
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
326
408
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
327
409
|
*/
|
|
328
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
410
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000 } = {}) {
|
|
329
411
|
let saved = null
|
|
330
412
|
if (dir) saved = readServiceIdentity(dir)
|
|
413
|
+
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
414
|
+
// enrolado por `@dotrino/remote-agent` (su `link.json` trae `device`, `cert` y `enc`).
|
|
415
|
+
// Un mismo enrolamiento sirve para el plano de control y para los secretos.
|
|
416
|
+
if (!saved && enc) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert, enc }
|
|
331
417
|
ns = ns || saved?.ns
|
|
332
418
|
proxyUrl = proxyUrl || saved?.proxy
|
|
333
419
|
masterPubkey = masterPubkey || saved?.iss
|
|
@@ -373,11 +459,110 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
373
459
|
if (!ok) throw new Error('invalid master signature on the secrets reply')
|
|
374
460
|
|
|
375
461
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
462
|
+
|
|
463
|
+
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
464
|
+
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
465
|
+
// ni los nombres de tus variables;
|
|
466
|
+
// · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
|
|
467
|
+
// sin poder abrirlo.
|
|
468
|
+
// Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
|
|
469
|
+
if (payload?.sealed) return openSealedBundle(payload.sealed, saved, payload.acta, masterPubkey)
|
|
470
|
+
|
|
471
|
+
// Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
|
|
472
|
+
// cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
|
|
376
473
|
if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
|
|
377
474
|
return payload.secrets
|
|
378
475
|
} finally { client.close() }
|
|
379
476
|
}
|
|
380
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
480
|
+
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
|
481
|
+
*
|
|
482
|
+
* Un fallo al abrir es un ERROR DURO, nunca un salto a lo del scope ni un valor
|
|
483
|
+
* omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
|
|
484
|
+
* con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
|
|
485
|
+
*/
|
|
486
|
+
/**
|
|
487
|
+
* ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
|
|
488
|
+
*
|
|
489
|
+
* Envolver una llave solo necesita públicas, así que **cualquiera puede fabricar un sobre
|
|
490
|
+
* válido** para este servicio: abrirlo prueba que es para mí, no que lo escribió quien
|
|
491
|
+
* debía. Lo que lo prueba es la firma, hecha con la llave de sellado que el acta nombra
|
|
492
|
+
* para el `seq` con el que se firmó — y el acta la firma la maestra, que es la que este
|
|
493
|
+
* agente lleva pineada desde que se enroló.
|
|
494
|
+
*
|
|
495
|
+
* Una firma que NO cuadra es un error duro: es exactamente el caso que esto viene a
|
|
496
|
+
* cazar. Un sobre SIN firma se acepta y se avisa: los hay de antes de que esto existiera
|
|
497
|
+
* y negarse a arrancar por eso apagaría servicios que llevan meses bien.
|
|
498
|
+
*/
|
|
499
|
+
export async function makeSealCheck (acta, masterPubkey, log = console.log) {
|
|
500
|
+
if (!acta) return () => {}
|
|
501
|
+
// El acta tiene que venir firmada por la maestra que este agente ya conoce. Si la
|
|
502
|
+
// selló otro (un traspaso que este agente no ha visto), no se puede establecer
|
|
503
|
+
// procedencia: se dice y se sigue, en vez de fingir que se comprobó.
|
|
504
|
+
const ok = acta.sealedBy === masterPubkey && (await verifyActa({ acta })).ok
|
|
505
|
+
if (!ok) {
|
|
506
|
+
log('[vault] ⚠ the record does not come from the master this agent knows: envelope provenance NOT checked')
|
|
507
|
+
return () => {}
|
|
508
|
+
}
|
|
509
|
+
let avisado = false
|
|
510
|
+
return async (owner, key, gen, e, seal) => {
|
|
511
|
+
if (!seal?.sig) {
|
|
512
|
+
if (!avisado) { avisado = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
const pub = sealKeyAt(acta, seal.seq)
|
|
516
|
+
if (!pub) throw new Error(`${key}: the record has no sealing key for #${seal.seq} (the envelope claims a record that does not exist)`)
|
|
517
|
+
const good = await verifyDeviceSig({ publickey: pub, data: { owner, key, gen, iv: e.iv, ct: e.ct }, signature: seal.sig })
|
|
518
|
+
if (!good) throw new Error(`${key}: the envelope signature does not check out — it did not come from this vault`)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null) {
|
|
523
|
+
if (!ident?.enc?.privateJwk) {
|
|
524
|
+
throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
|
|
525
|
+
}
|
|
526
|
+
const mine = await importDeviceEncKey(ident.enc.privateJwk)
|
|
527
|
+
|
|
528
|
+
// UNA LLAVE POR GENERACIÓN, no una por cajón. Desde v5 cada escritura estrena
|
|
529
|
+
// generación —la bóveda no puede reutilizar una llave que no puede abrir—, así que dos
|
|
530
|
+
// variables del mismo cajón pueden venir de generaciones distintas. El bundle trae
|
|
531
|
+
// TODAS las envolturas de este aparato; se abren perezosamente, solo las que hagan
|
|
532
|
+
// falta. `sealed.ns`/`sealed.dev` (una sola, la vigente) siguen entrando: es el bundle
|
|
533
|
+
// de v4 y sirve para lo que ese vault selló.
|
|
534
|
+
const porGen = { ns: new Map(), dev: new Map() }
|
|
535
|
+
const añade = (cual, info) => { if (info?.wrap) porGen[cual].set(info.gen ?? 0, info.wrap) }
|
|
536
|
+
añade('ns', sealed.ns); añade('dev', sealed.dev)
|
|
537
|
+
for (const cual of ['ns', 'dev']) for (const info of sealed.wraps?.[cual] || []) añade(cual, info)
|
|
538
|
+
|
|
539
|
+
const abiertas = { ns: new Map(), dev: new Map() }
|
|
540
|
+
const cekDe = async (cual, gen) => {
|
|
541
|
+
if (abiertas[cual].has(gen)) return abiertas[cual].get(gen)
|
|
542
|
+
// Un bundle de v4 no traía `gen` en la envoltura: si solo hay una, es esa.
|
|
543
|
+
const wrap = porGen[cual].get(gen) ?? (porGen[cual].size === 1 ? [...porGen[cual].values()][0] : null)
|
|
544
|
+
if (!wrap) return null
|
|
545
|
+
const cek = await openWrap({ wrap, myEncPrivateKey: mine })
|
|
546
|
+
abiertas[cual].set(gen, cek)
|
|
547
|
+
return cek
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const comprobarFirma = await makeSealCheck(acta, masterPubkey)
|
|
551
|
+
|
|
552
|
+
const out = {}
|
|
553
|
+
for (const [key, e] of Object.entries(sealed.entries || {})) {
|
|
554
|
+
if (e.pub) { out[key] = e.v; continue }
|
|
555
|
+
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
556
|
+
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
557
|
+
const gen = e.gen ?? e.e?.gen ?? 0
|
|
558
|
+
await comprobarFirma(e.owner, key, gen, e.e, e.seal)
|
|
559
|
+
const cek = await cekDe(cual, gen)
|
|
560
|
+
if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
|
|
561
|
+
out[key] = await decryptWithCek({ cek, envelope: e.e })
|
|
562
|
+
}
|
|
563
|
+
return out
|
|
564
|
+
}
|
|
565
|
+
|
|
381
566
|
/**
|
|
382
567
|
* Escucha los avisos de cambio de configuración de la bóveda.
|
|
383
568
|
*
|
|
@@ -489,8 +674,59 @@ export async function watchSecretsChanges ({
|
|
|
489
674
|
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
490
675
|
}
|
|
491
676
|
|
|
677
|
+
/**
|
|
678
|
+
* REPARTIR LA LLAVE DE MI CAJÓN a un miembro nuevo (§8.11 del diseño).
|
|
679
|
+
*
|
|
680
|
+
* Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
|
|
681
|
+
* la bóveda no se la puede hacer: envolver exige abrir la llave, y abrirla pide la
|
|
682
|
+
* frase. Este agente SÍ la tiene abierta, así que la reparte él. No gana ningún poder
|
|
683
|
+
* haciéndolo —ya podía leer eso— y por eso es el único que puede hacerlo sin que
|
|
684
|
+
* nadie ceda nada.
|
|
685
|
+
*
|
|
686
|
+
* NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
|
|
687
|
+
* bóveda estuviera comprometida:
|
|
688
|
+
*
|
|
689
|
+
* · la petición va firmada por la MAESTRA;
|
|
690
|
+
* · el acta viaja dentro y se comprueba aparte (también la firma la maestra);
|
|
691
|
+
* · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
|
|
692
|
+
* se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
|
|
693
|
+
* la llave para una pública suya;
|
|
694
|
+
* · y el destinatario tiene que ser de ESTE cajón (`cn === ns`): un servicio no puede
|
|
695
|
+
* ampliar el acceso a nada que no sea lo suyo.
|
|
696
|
+
*/
|
|
697
|
+
const handleRewrap = async (payload) => {
|
|
698
|
+
const body = payload?.body
|
|
699
|
+
if (!body || body.op !== 'rewrap') return
|
|
700
|
+
const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
|
|
701
|
+
if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
|
|
702
|
+
if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
|
|
703
|
+
return log('[vault] rewrap request BADLY SIGNED: ignored')
|
|
704
|
+
}
|
|
705
|
+
const acta = body.acta
|
|
706
|
+
if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
|
|
707
|
+
return log('[vault] rewrap request without a valid record: ignored')
|
|
708
|
+
}
|
|
709
|
+
const target = (acta.members || []).find((m) => m.pub === body.target)
|
|
710
|
+
if (!target?.encPub) return log('[vault] rewrap: the target is not in the record (or has no encryption key)')
|
|
711
|
+
if (target.cn !== ns) return log(`[vault] rewrap: ${String(body.target).slice(0, 12)}… is not part of «${ns}»: refused`)
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
const ident = readServiceIdentity(dir)
|
|
715
|
+
if (!ident?.enc?.privateJwk) return log('[vault] rewrap: this agent has no encryption key')
|
|
716
|
+
const cek = await openWrap({ wrap: body.wrap, myEncPrivateKey: await importDeviceEncKey(ident.enc.privateJwk) })
|
|
717
|
+
const wrap = await wrapForMember({ cek, memberEncPub: target.encPub })
|
|
718
|
+
const data = { op: 'rewrap.ok', owner: body.owner, gen: body.gen, target: body.target, wrap, ts: Date.now() }
|
|
719
|
+
const { signature } = await signWithDevice({ privateJwk: saved.device.privateJwk, data })
|
|
720
|
+
client.sendByPubkey(master, { type: MSG.REWRAP_OK, data, signature, cert: saved.cert })
|
|
721
|
+
log(`[vault] key handed to ${String(body.target).slice(0, 12)}… for ${body.owner} (gen ${body.gen})`)
|
|
722
|
+
} catch (e) {
|
|
723
|
+
log('[vault] rewrap failed: ' + e.message)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
492
727
|
const handleMessage = async (payload) => {
|
|
493
728
|
if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
|
|
729
|
+
if (payload?.type === MSG.REWRAP) return handleRewrap(payload)
|
|
494
730
|
if (payload?.type !== MSG.SECRETS_CHANGED) return
|
|
495
731
|
const body = payload.body
|
|
496
732
|
if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
|
|
@@ -646,11 +882,11 @@ function fingerprintOf (secrets) {
|
|
|
646
882
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
647
883
|
* @returns {Promise<Record<string,string>>}
|
|
648
884
|
*/
|
|
649
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
|
|
885
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
|
|
650
886
|
let delay = retryMs
|
|
651
887
|
for (;;) {
|
|
652
888
|
try {
|
|
653
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
|
|
889
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
|
|
654
890
|
} catch (e) {
|
|
655
891
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
656
892
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.2",
|
|
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.
|
|
24
|
-
"@dotrino/proxy-client": "^0.10.
|
|
23
|
+
"@dotrino/identity": "^0.57.0",
|
|
24
|
+
"@dotrino/proxy-client": "^0.10.1",
|
|
25
25
|
"ws": "^8.18.0"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"!lib/src/types.d.ts"
|
|
44
44
|
],
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@dotrino/remote-agent": "^0.3.
|
|
47
|
-
"
|
|
48
|
-
"
|
|
46
|
+
"@dotrino/remote-agent": "^0.3.2",
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"typescript": "5.9.3"
|
|
49
49
|
}
|
|
50
50
|
}
|