@dotrino/vault 0.28.0 → 0.30.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 +4 -1
- package/src/admin.js +5 -34
- package/src/service.js +85 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"./sealed": {
|
|
25
25
|
"import": "./src/sealed.js"
|
|
26
26
|
},
|
|
27
|
+
"./invite": {
|
|
28
|
+
"import": "./src/invite.js"
|
|
29
|
+
},
|
|
27
30
|
"./protocol": {
|
|
28
31
|
"import": "./src/protocol.js"
|
|
29
32
|
}
|
package/src/admin.js
CHANGED
|
@@ -41,12 +41,11 @@ 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
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
'var.reveal', 'var.history'
|
|
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.
|
|
48
|
+
'vars', 'var.set', 'var.setMany'
|
|
50
49
|
])
|
|
51
50
|
|
|
52
51
|
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
@@ -217,34 +216,6 @@ export function createAdminDesk ({
|
|
|
217
216
|
return { ok: true, result: result || { ok: true } }
|
|
218
217
|
}
|
|
219
218
|
|
|
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
|
-
|
|
248
219
|
// QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
|
|
249
220
|
// todos los certificados. Las dos cosas o ninguna.
|
|
250
221
|
//
|
package/src/service.js
CHANGED
|
@@ -224,47 +224,37 @@ function writeServiceIdentity (dir, obj) {
|
|
|
224
224
|
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
225
225
|
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
226
226
|
*/
|
|
227
|
-
|
|
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 } = {}) {
|
|
228
244
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
229
|
-
// `dotrino-vault pair
|
|
230
|
-
//
|
|
231
|
-
// «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
|
|
232
|
-
// este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
|
|
233
|
-
// cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
|
|
234
|
-
// 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.
|
|
235
247
|
if (typeof qr === 'string') {
|
|
236
248
|
const o = parseInvite(qr)
|
|
237
|
-
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`)')
|
|
238
250
|
qr = o
|
|
239
251
|
}
|
|
240
252
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
|
|
241
253
|
rejectAdoption(qr.m)
|
|
242
|
-
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
243
|
-
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
244
|
-
label = label || 'service:' + ns
|
|
245
|
-
|
|
246
|
-
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
247
|
-
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
248
|
-
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
249
|
-
// que se re-pineen a mano.
|
|
250
|
-
const previous = readServiceIdentity(dir)
|
|
251
|
-
let replaced = null
|
|
252
|
-
if (previous?.device?.publickey) {
|
|
253
|
-
replaced = {
|
|
254
|
-
ns: previous.ns,
|
|
255
|
-
enrolledAt: previous.enrolledAt,
|
|
256
|
-
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
257
|
-
}
|
|
258
|
-
try { onReplace?.(replaced) } catch (_) {}
|
|
259
|
-
}
|
|
260
254
|
|
|
261
255
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
262
256
|
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
263
257
|
if (!qr.iss) {
|
|
264
|
-
// `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
|
|
265
|
-
// canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
|
|
266
|
-
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
267
|
-
// aunque la bóveda esté en otro proxio de la malla.
|
|
268
258
|
const target = await resolveAppointment(client, qr.conn)
|
|
269
259
|
const hello = await new Promise((resolve, reject) => {
|
|
270
260
|
const off = client.on('message', (_f, p) => {
|
|
@@ -279,22 +269,14 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
279
269
|
}
|
|
280
270
|
try {
|
|
281
271
|
const device = await makeDeviceKey({ label })
|
|
282
|
-
// La llave de CIFRADO
|
|
283
|
-
//
|
|
284
|
-
// que nadie sepa por qué. Su pública viaja en el enrolamiento y acaba en el
|
|
285
|
-
// acta como `encPub`; la privada no sale de este disco.
|
|
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.
|
|
286
274
|
const enc = await makeDeviceEncKey()
|
|
287
275
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
288
|
-
// Código ALEATORIO
|
|
289
|
-
//
|
|
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.
|
|
290
278
|
const code = makePairingCode()
|
|
291
|
-
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
292
|
-
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
293
279
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
294
|
-
// `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
|
|
295
|
-
// si falta, asume `join` — pero un agente no debe apoyarse en un default
|
|
296
|
-
// para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
|
|
297
|
-
// firmado: nadie en el medio puede convertirlo en una adopción.
|
|
298
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() }
|
|
299
281
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
300
282
|
|
|
@@ -302,7 +284,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
302
284
|
const off = client.on('message', (_from, p) => {
|
|
303
285
|
if (!p || typeof p !== 'object') return
|
|
304
286
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
305
|
-
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}`))
|
|
306
288
|
show({ deviceId, code })
|
|
307
289
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
308
290
|
})
|
|
@@ -312,35 +294,71 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
312
294
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
313
295
|
const res = await enrolled
|
|
314
296
|
|
|
315
|
-
// Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
|
|
316
|
-
// para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
|
|
317
297
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
318
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope:
|
|
298
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
319
299
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
320
300
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
321
301
|
|
|
322
|
-
|
|
323
|
-
// anterior deja de existir en este agente.
|
|
324
|
-
// v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
|
|
325
|
-
// `nodeId` del proxio, y cambiarlo le costaría su cajón de variables.
|
|
326
|
-
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() })
|
|
327
|
-
return { device, enc, cert: res.cert, iss: qr.iss, replaced }
|
|
302
|
+
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
328
303
|
} finally { client.close() }
|
|
329
304
|
}
|
|
330
305
|
|
|
331
306
|
/**
|
|
332
|
-
*
|
|
307
|
+
* Enrola ESTE servicio contra el vault y persiste su identidad.
|
|
333
308
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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".
|
|
339
315
|
*
|
|
340
|
-
*
|
|
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.
|
|
341
319
|
*
|
|
342
|
-
*
|
|
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}>}
|
|
343
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
|
+
|
|
344
362
|
export async function ensureEncKey ({ dir } = {}) {
|
|
345
363
|
const saved = readServiceIdentity(dir)
|
|
346
364
|
if (!saved) throw new Error('service not enrolled: run enrollService() first')
|
|
@@ -389,9 +407,13 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
389
407
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
390
408
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
391
409
|
*/
|
|
392
|
-
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 } = {}) {
|
|
393
411
|
let saved = null
|
|
394
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 }
|
|
395
417
|
ns = ns || saved?.ns
|
|
396
418
|
proxyUrl = proxyUrl || saved?.proxy
|
|
397
419
|
masterPubkey = masterPubkey || saved?.iss
|
|
@@ -860,11 +882,11 @@ function fingerprintOf (secrets) {
|
|
|
860
882
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
861
883
|
* @returns {Promise<Record<string,string>>}
|
|
862
884
|
*/
|
|
863
|
-
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 } = {}) {
|
|
864
886
|
let delay = retryMs
|
|
865
887
|
for (;;) {
|
|
866
888
|
try {
|
|
867
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
|
|
889
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
|
|
868
890
|
} catch (e) {
|
|
869
891
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
870
892
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|