@dotrino/vaultd 0.12.0 → 0.13.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/README.md +534 -188
- package/lib/README.md +136 -5
- package/lib/src/admin.js +146 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +38 -6
- package/lib/src/enroll.js +29 -29
- package/lib/src/env.js +119 -8
- package/lib/src/index.js +70 -18
- package/lib/src/protocol.js +29 -1
- package/lib/src/sealed.js +1 -1
- package/lib/src/service.js +238 -13
- package/package.json +7 -4
- package/src/atrest.js +0 -0
- package/src/client.js +64 -6
- package/src/ctl.js +10 -5
- package/src/daemon.js +19 -19
- package/src/manager.js +2 -2
- package/src/paths.js +24 -8
- package/src/profiles.js +14 -8
- package/src/secretsStore.js +14 -11
- package/src/store.js +12 -7
- package/src/threadStore.js +76 -4
- package/src/vault.js +197 -32
- package/src/vaultControl.js +8 -8
package/lib/src/service.js
CHANGED
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from '@dotrino/identity/capabilities'
|
|
27
27
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
28
28
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
29
|
+
import { parseInvite } from './invite.js'
|
|
30
|
+
import { atRestFor } from './atrest.js'
|
|
29
31
|
|
|
30
32
|
const IDENTITY_FILE = 'service-identity.json'
|
|
31
33
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
@@ -74,9 +76,36 @@ async function verificarHola (p, sn) {
|
|
|
74
76
|
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
75
77
|
throw new Error('la respuesta de la bóveda no está bien firmada')
|
|
76
78
|
}
|
|
79
|
+
// El modo también viene aquí, y aquí viene FIRMADO por la bóveda. Se comprueba
|
|
80
|
+
// de nuevo aunque ya se haya mirado el del QR: en la forma corta el QR es un
|
|
81
|
+
// código que pasó por manos ajenas, y esta es la primera vez que la bóveda
|
|
82
|
+
// dice de su puño y letra qué se propone hacer.
|
|
83
|
+
rechazarAdopcion(b.m)
|
|
77
84
|
return b
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* UN AGENTE NUNCA TRANSFIERE SU IDENTIDAD: el vault propone, el agente acepta.
|
|
89
|
+
*
|
|
90
|
+
* El emparejamiento tiene dos modos y los declara la bóveda en la invitación:
|
|
91
|
+
* `join` (el que se enrola entra a la cuenta de la bóveda) y `adopt` (la bóveda
|
|
92
|
+
* se queda con la cuenta que trae el aparato). El segundo existe para APARATOS,
|
|
93
|
+
* que llegan con una cuenta propia y una historia que conservar.
|
|
94
|
+
*
|
|
95
|
+
* Un agente no tiene nada de eso: es un servicio, su identidad se la da el vault
|
|
96
|
+
* y no hay caso en que quiera empujar la suya hacia arriba. Así que este camino
|
|
97
|
+
* no se negocia, se rechaza — y se rechaza ACÁ, cuando el humano pega la
|
|
98
|
+
* invitación, en vez de dejar que el viaje termine en un «intent-mismatch» del
|
|
99
|
+
* otro lado que no le explica nada a nadie.
|
|
100
|
+
*/
|
|
101
|
+
function rechazarAdopcion (modo) {
|
|
102
|
+
if (modo !== 'adopt') return
|
|
103
|
+
throw new Error(
|
|
104
|
+
'esta invitación se abrió para ADOPTAR la cuenta del aparato, y un agente no transfiere su identidad: ' +
|
|
105
|
+
'la suya se la cede el vault. Abre el emparejamiento sin `--adopt` (`dotrino-vault pair --service <ns>`).'
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
/**
|
|
81
110
|
* Canjea la cita del QR y devuelve la instancia a la que apunta.
|
|
82
111
|
*
|
|
@@ -141,38 +170,91 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
|
141
170
|
|
|
142
171
|
const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
|
|
143
172
|
|
|
144
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null.
|
|
175
|
+
*
|
|
176
|
+
* CIFRADA EN REPOSO (`atrest.js`) con una clave ligada a ESTA máquina: el archivo
|
|
177
|
+
* lleva la llave privada del dispositivo, así que copiarlo a otro equipo no sirve.
|
|
178
|
+
* Un archivo de una versión anterior (en claro) se lee igual y queda cifrado en la
|
|
179
|
+
* primera escritura.
|
|
180
|
+
*/
|
|
145
181
|
export function readServiceIdentity (dir) {
|
|
146
|
-
try {
|
|
182
|
+
try {
|
|
183
|
+
const text = fs.readFileSync(identityFileOf(dir), 'utf8')
|
|
184
|
+
return JSON.parse(atRestFor(dir).decrypt(text))
|
|
185
|
+
} catch (_) { return null }
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
function writeServiceIdentity (dir, obj) {
|
|
150
189
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
151
190
|
const f = identityFileOf(dir)
|
|
152
|
-
|
|
191
|
+
const blob = atRestFor(dir).encrypt(JSON.stringify(obj, null, 2))
|
|
192
|
+
fs.writeFileSync(f, blob, { mode: 0o600 })
|
|
153
193
|
}
|
|
154
194
|
|
|
155
195
|
/**
|
|
156
|
-
* Enrola ESTE servicio contra el vault
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
196
|
+
* Enrola ESTE servicio contra el vault y persiste su identidad.
|
|
197
|
+
*
|
|
198
|
+
* UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
|
|
199
|
+
* aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
|
|
200
|
+
* adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
|
|
201
|
+
* el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
|
|
202
|
+
* había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
|
|
203
|
+
* es un servicio, no una persona; no tiene por qué "ser varios".
|
|
204
|
+
*
|
|
205
|
+
* Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
|
|
206
|
+
* es además la forma de rotar la identidad de un agente comprometido. Lo que sí
|
|
207
|
+
* hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
|
|
208
|
+
*
|
|
209
|
+
* En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
|
|
210
|
+
* que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
|
|
211
|
+
* Muestra un código por `onCode`: el dueño lo tipea en el vault
|
|
212
|
+
* (`dotrino-vault approve <código>`).
|
|
160
213
|
*
|
|
161
214
|
* @param {Object} opts
|
|
162
|
-
* @param {
|
|
215
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
163
216
|
* @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
|
|
164
217
|
* @param {string} opts.dir Dónde persistir `service-identity.json`.
|
|
165
218
|
* @param {string} [opts.label]
|
|
166
219
|
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
167
|
-
* @
|
|
220
|
+
* @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
|
|
221
|
+
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
222
|
+
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
168
223
|
*/
|
|
169
|
-
export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
|
|
170
|
-
|
|
224
|
+
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
225
|
+
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
226
|
+
// `dotrino-vault pair --service` emite la URL del QR y el código compacto
|
|
227
|
+
// (`c…`/`t…`, ver invite.js), así que un `JSON.parse` fallaba SIEMPRE con
|
|
228
|
+
// «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
|
|
229
|
+
// este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
|
|
230
|
+
// cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
|
|
231
|
+
// incluida la vieja, así que esto entiende cualquier invitación.
|
|
232
|
+
if (typeof qr === 'string') {
|
|
233
|
+
const o = parseInvite(qr)
|
|
234
|
+
if (!o) throw new Error('eso no parece una invitación del vault (pega la salida de `dotrino-vault pair --service <ns>`)')
|
|
235
|
+
qr = o
|
|
236
|
+
}
|
|
171
237
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
|
|
238
|
+
rechazarAdopcion(qr.m)
|
|
172
239
|
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
|
|
173
240
|
if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
|
|
174
241
|
label = label || 'servicio:' + ns
|
|
175
242
|
|
|
243
|
+
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
244
|
+
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
245
|
+
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
246
|
+
// que se re-pineen a mano.
|
|
247
|
+
const anterior = readServiceIdentity(dir)
|
|
248
|
+
let replaced = null
|
|
249
|
+
if (anterior?.device?.publickey) {
|
|
250
|
+
replaced = {
|
|
251
|
+
ns: anterior.ns,
|
|
252
|
+
enrolledAt: anterior.enrolledAt,
|
|
253
|
+
deviceId: (await pubkeyId(anterior.device.publickey)).slice(0, 8).toUpperCase()
|
|
254
|
+
}
|
|
255
|
+
try { onReplace?.(replaced) } catch (_) {}
|
|
256
|
+
}
|
|
257
|
+
|
|
176
258
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
177
259
|
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
178
260
|
if (!qr.iss) {
|
|
@@ -201,7 +283,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
|
|
|
201
283
|
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
202
284
|
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
203
285
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
204
|
-
|
|
286
|
+
// `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
|
|
287
|
+
// si falta, asume `join` — pero un agente no debe apoyarse en un default
|
|
288
|
+
// para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
|
|
289
|
+
// firmado: nadie en el medio puede convertirlo en una adopción.
|
|
290
|
+
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
205
291
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
206
292
|
|
|
207
293
|
const enrolled = new Promise((resolve, reject) => {
|
|
@@ -225,8 +311,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
|
|
|
225
311
|
if (!v.ok) throw new Error('cert inválido: ' + v.reason)
|
|
226
312
|
if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
|
|
227
313
|
|
|
314
|
+
// Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
|
|
315
|
+
// anterior deja de existir en este agente.
|
|
228
316
|
writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
|
|
229
|
-
return { device, cert: res.cert, iss: qr.iss }
|
|
317
|
+
return { device, cert: res.cert, iss: qr.iss, replaced }
|
|
230
318
|
} finally { client.close() }
|
|
231
319
|
}
|
|
232
320
|
|
|
@@ -289,6 +377,143 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
289
377
|
} finally { client.close() }
|
|
290
378
|
}
|
|
291
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Escucha los avisos de cambio de configuración de la bóveda.
|
|
382
|
+
*
|
|
383
|
+
* A diferencia de `fetchSecrets` —que abre, pide y cierra—, esto mantiene la
|
|
384
|
+
* conexión ABIERTA e identificada con la llave del servicio: es la dirección a la
|
|
385
|
+
* que la bóveda le habla. Por eso un agente que quiera enterarse de una rotación
|
|
386
|
+
* deja de ser un cliente de paso y pasa a ser uno permanente.
|
|
387
|
+
*
|
|
388
|
+
* Lo que NO hace: recargar nada. El aviso no trae valores, y la reacción correcta
|
|
389
|
+
* es que el proceso termine y lo levante su supervisor (ver `watchEnv`).
|
|
390
|
+
*
|
|
391
|
+
* Defensas, porque una señal que provoca reinicios es un arma si se descuida:
|
|
392
|
+
* · **Firma de la maestra pineada** y `ns` que coincida. Sin esto, cualquiera
|
|
393
|
+
* reinicia la flota ajena cuando quiera.
|
|
394
|
+
* · **Frescura y anti-replay**: `ts` dentro de la ventana y estrictamente mayor
|
|
395
|
+
* que el último obedecido. Un aviso viejo reproducido no vuelve a disparar.
|
|
396
|
+
* · **Gracia de arranque y piso entre avisos**: no se obedece recién arrancado ni
|
|
397
|
+
* dos veces seguidas. Si la configuración nueva rompe el arranque, sin esto el
|
|
398
|
+
* servicio entra en un ciclo de reinicios.
|
|
399
|
+
* · **Jitter**: diez agentes del mismo ns no pueden salir todos en el mismo
|
|
400
|
+
* segundo.
|
|
401
|
+
*
|
|
402
|
+
* @param {Object} opts
|
|
403
|
+
* @param {string} opts.dir Identidad del servicio (`service-identity.json`).
|
|
404
|
+
* @param {string} [opts.ns]
|
|
405
|
+
* @param {(info:{ns:string, ts:number})=>void} opts.onChange
|
|
406
|
+
* @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
|
|
407
|
+
* @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
|
|
408
|
+
* @param {number} [opts.minIntervalMs=60000] Mínimo entre dos avisos obedecidos.
|
|
409
|
+
* @param {number} [opts.jitterMs=5000] Espera aleatoria antes de avisar.
|
|
410
|
+
* @param {(m:string)=>void} [opts.log]
|
|
411
|
+
* @returns {Promise<{stop:()=>void}>}
|
|
412
|
+
*/
|
|
413
|
+
export async function watchSecretsChanges ({
|
|
414
|
+
dir, ns, onChange, onRevoked, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000, log = () => {}
|
|
415
|
+
} = {}) {
|
|
416
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
417
|
+
ns = ns || saved?.ns
|
|
418
|
+
if (!saved?.device || !saved?.cert || !saved?.iss || !saved?.proxy) {
|
|
419
|
+
throw new Error('servicio sin enrolar: no hay a quién escuchar')
|
|
420
|
+
}
|
|
421
|
+
const master = saved.iss
|
|
422
|
+
const nacido = Date.now()
|
|
423
|
+
let ultimoTs = 0
|
|
424
|
+
let ultimoObedecido = 0
|
|
425
|
+
const enVuelo = new Set() // avisos cuya firma se está comprobando ahora mismo
|
|
426
|
+
let parado = false
|
|
427
|
+
let client = null
|
|
428
|
+
let reintento = null
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* REVOCACIÓN = interruptor de emergencia. Hasta ahora revocar un cert no le
|
|
432
|
+
* quitaba nada a un servicio YA CORRIENDO: seguía operando con los secretos en
|
|
433
|
+
* memoria hasta que alguien se acordara de reiniciarlo (el README decía lo
|
|
434
|
+
* contrario). Teniendo la conexión abierta, el aviso llega y el agente se apaga
|
|
435
|
+
* en el acto — y no vuelve, porque al arrancar `fetchSecrets` recibe
|
|
436
|
+
* «no autorizado: revoked», que no se arregla reintentando.
|
|
437
|
+
*
|
|
438
|
+
* Sin gracia, sin piso y sin jitter, al revés que un cambio de configuración:
|
|
439
|
+
* apagar algo comprometido es justo lo que no debe esperar su turno.
|
|
440
|
+
*/
|
|
441
|
+
const atenderRevocacion = async (payload) => {
|
|
442
|
+
const body = payload.body
|
|
443
|
+
if (!body || body.op !== 'revoke') return
|
|
444
|
+
// Que sea MI revocación y no la de otro dispositivo del mismo dueño.
|
|
445
|
+
if (body.sub !== saved.device.publickey) return
|
|
446
|
+
if (saved.cert?.nonce && body.nonce !== saved.cert.nonce) return
|
|
447
|
+
if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
|
|
448
|
+
return log('[vault] revocation notice BADLY SIGNED: ignored')
|
|
449
|
+
}
|
|
450
|
+
log('[vault] ⚠ the vault REVOKED this agent cert: shutting down')
|
|
451
|
+
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const atender = async (payload) => {
|
|
455
|
+
if (payload?.type === MSG.REVOKED) return atenderRevocacion(payload)
|
|
456
|
+
if (payload?.type !== MSG.SECRETS_CHANGED) return
|
|
457
|
+
const body = payload.body
|
|
458
|
+
if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
|
|
459
|
+
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) {
|
|
460
|
+
return log('[vault] aviso de cambio con fecha fuera de ventana: ignorado')
|
|
461
|
+
}
|
|
462
|
+
if (body.ts <= ultimoTs) return log('[vault] aviso de cambio repetido: ignorado')
|
|
463
|
+
// Dos copias del MISMO aviso pueden llegar a la vez, y comprobar la firma es
|
|
464
|
+
// asíncrono: sin esta marca las dos pasarían el corte de `ultimoTs` antes de
|
|
465
|
+
// que ninguna lo actualizara, y el agente se reiniciaría por partida doble.
|
|
466
|
+
// La marca se pone antes del `await` y el `ultimoTs` DESPUÉS de verificar, para
|
|
467
|
+
// que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
|
|
468
|
+
if (enVuelo.has(body.ts)) return
|
|
469
|
+
enVuelo.add(body.ts)
|
|
470
|
+
let valida = false
|
|
471
|
+
try {
|
|
472
|
+
valida = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
|
|
473
|
+
} finally { enVuelo.delete(body.ts) }
|
|
474
|
+
if (!valida) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
|
|
475
|
+
if (body.ts <= ultimoTs) return
|
|
476
|
+
ultimoTs = body.ts
|
|
477
|
+
|
|
478
|
+
const ahora = Date.now()
|
|
479
|
+
if (ahora - nacido < graceMs) {
|
|
480
|
+
return log('[vault] change notice right after start: ignored (avoids the restart loop)')
|
|
481
|
+
}
|
|
482
|
+
if (ahora - ultimoObedecido < minIntervalMs) {
|
|
483
|
+
return log('[vault] aviso de cambio demasiado seguido del anterior: ignorado')
|
|
484
|
+
}
|
|
485
|
+
ultimoObedecido = ahora
|
|
486
|
+
|
|
487
|
+
const espera = Math.floor(Math.random() * jitterMs)
|
|
488
|
+
log(`[vault] the vault reports config for "${ns}" changed (in ${espera} ms)`)
|
|
489
|
+
setTimeout(() => { if (!parado) { try { onChange?.({ ns, ts: body.ts }) } catch (e) { log('[vault] ' + e.message) } } }, espera)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const conectar = async () => {
|
|
493
|
+
if (parado) return
|
|
494
|
+
try {
|
|
495
|
+
client = await freshClient(saved.proxy)
|
|
496
|
+
await identifyAsService(client, saved.device)
|
|
497
|
+
client.on('message', (_from, p) => { atender(p).catch(() => {}) })
|
|
498
|
+
// Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
|
|
499
|
+
// eso es exactamente el momento en que uno querría enterarse de una rotación.
|
|
500
|
+
client.on('disconnected', () => { if (!parado) reintento = setTimeout(conectar, 5000) })
|
|
501
|
+
log('[vault] listening for config changes')
|
|
502
|
+
} catch (e) {
|
|
503
|
+
if (!parado) reintento = setTimeout(conectar, 5000)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await conectar()
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
stop () {
|
|
510
|
+
parado = true
|
|
511
|
+
clearTimeout(reintento)
|
|
512
|
+
try { client?.close() } catch (_) {}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
292
517
|
/**
|
|
293
518
|
* Bucle de arranque de un servicio: pide los secretos y, si el vault no está
|
|
294
519
|
* disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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": {
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"node": ">=20"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@dotrino/identity": "^0.
|
|
23
|
-
"@dotrino/proxy-client": "^0.
|
|
22
|
+
"@dotrino/identity": "^0.38.0",
|
|
23
|
+
"@dotrino/proxy-client": "^0.10.0",
|
|
24
24
|
"ws": "^8.18.0"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
@@ -38,5 +38,8 @@
|
|
|
38
38
|
"README.md",
|
|
39
39
|
"vendor",
|
|
40
40
|
"lib/src"
|
|
41
|
-
]
|
|
41
|
+
],
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@dotrino/remote-agent": "^0.3.0"
|
|
44
|
+
}
|
|
42
45
|
}
|
package/src/atrest.js
CHANGED
|
Binary file
|
package/src/client.js
CHANGED
|
@@ -36,7 +36,7 @@ function waitFor (client, predicate, timeoutMs = 30000) {
|
|
|
36
36
|
const off = client.on('message', (_from, payload) => {
|
|
37
37
|
if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
|
|
38
38
|
})
|
|
39
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
39
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for the vault response')) }, timeoutMs)
|
|
40
40
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
41
41
|
})
|
|
42
42
|
}
|
|
@@ -51,7 +51,7 @@ function waitFor (client, predicate, timeoutMs = 30000) {
|
|
|
51
51
|
* @returns {Promise<{ device, cert, iss:string }>} GUARDAR `device` (incluye la privada) + `cert`. `iss` = qr.iss verificado.
|
|
52
52
|
*/
|
|
53
53
|
export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeoutMs = 180000 } = {}) {
|
|
54
|
-
if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr
|
|
54
|
+
if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('invalid qr (v2): missing iss/proxy/token/sn')
|
|
55
55
|
const client = await freshClient({ proxyUrl: qr.proxy, dir })
|
|
56
56
|
try {
|
|
57
57
|
const device = await makeDeviceKey({ label })
|
|
@@ -77,7 +77,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
|
|
|
77
77
|
cleanup(); reject(new Error(p.error))
|
|
78
78
|
}
|
|
79
79
|
})
|
|
80
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
80
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval at the vault')) }, approveTimeoutMs)
|
|
81
81
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
82
82
|
})
|
|
83
83
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
@@ -85,9 +85,9 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
|
|
|
85
85
|
|
|
86
86
|
// VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
|
|
87
87
|
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
88
|
-
if (!v.ok) throw new Error('cert
|
|
89
|
-
if (res.cert.iss !== qr.iss) throw new Error('cert
|
|
90
|
-
if (res.cert.sub !== device.publickey) throw new Error('cert
|
|
88
|
+
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
89
|
+
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
|
|
90
|
+
if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
|
|
91
91
|
// OJO: devolvemos qr.iss (la maestra que el usuario VIO), NO res.iss.
|
|
92
92
|
return { device, cert: res.cert, iss: qr.iss }
|
|
93
93
|
} finally { client.close() }
|
|
@@ -132,6 +132,32 @@ export async function requestGet ({ masterPubkey, proxyUrl, device, cert, id = '
|
|
|
132
132
|
} finally { client.close() }
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* RENUEVA el cert de este dispositivo (sigue siendo el mismo: no hay QR ni aprobación).
|
|
137
|
+
*
|
|
138
|
+
* No es solo estirar la fecha: el scope del cert nuevo lo decide **el acta**, así que
|
|
139
|
+
* renovar es también como llega a un dispositivo un permiso que el dueño le concedió
|
|
140
|
+
* después de emparejarlo —y como se le cae uno que le quitaron—. Un cert vencido o
|
|
141
|
+
* revocado no se renueva: ahí toca volver a emparejar.
|
|
142
|
+
*/
|
|
143
|
+
export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir } = {}) {
|
|
144
|
+
const client = await freshClient({ proxyUrl, dir })
|
|
145
|
+
try {
|
|
146
|
+
const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
|
|
147
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
148
|
+
const pending = waitFor(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR)
|
|
149
|
+
client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
|
|
150
|
+
const res = await pending
|
|
151
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
152
|
+
// Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
|
|
153
|
+
// firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
|
|
154
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
155
|
+
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
156
|
+
if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
|
|
157
|
+
return { cert: res.cert }
|
|
158
|
+
} finally { client.close() }
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
/** Llama un método del store de hilos/aperturas del vault (scope vault:store). */
|
|
136
162
|
export async function requestStore ({ masterPubkey, proxyUrl, device, cert, method, args, dir } = {}) {
|
|
137
163
|
const client = await freshClient({ proxyUrl, dir })
|
|
@@ -146,4 +172,36 @@ export async function requestStore ({ masterPubkey, proxyUrl, device, cert, meth
|
|
|
146
172
|
} finally { client.close() }
|
|
147
173
|
}
|
|
148
174
|
|
|
175
|
+
/**
|
|
176
|
+
* CONSOLA REMOTA (scope `vault:admin`, docs/consola-remota.md): administrar el perfil
|
|
177
|
+
* desde un dispositivo, sin venir al PC. `op`: pending · pair · approve · reject ·
|
|
178
|
+
* revoke · audit.
|
|
179
|
+
*
|
|
180
|
+
* El `nonce` de un solo uso NO es decorativo: `approve` y `revoke` cambian estado, así
|
|
181
|
+
* que la ventana de frescura de ±5 min no basta para descartar un replay.
|
|
182
|
+
*/
|
|
183
|
+
export async function requestAdmin ({ masterPubkey, proxyUrl, device, cert, op, dir, ...rest } = {}) {
|
|
184
|
+
const client = await freshClient({ proxyUrl, dir })
|
|
185
|
+
try {
|
|
186
|
+
const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
187
|
+
const data = { op, ...rest, publickey: device.publickey, ts: Date.now(), nonce }
|
|
188
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
189
|
+
const pending = waitFor(client, (p) => p.type === MSG.ADMIN_RESULT || p.type === MSG.ERROR)
|
|
190
|
+
client.sendByPubkey(masterPubkey, { type: MSG.ADMIN, data, signature, cert })
|
|
191
|
+
const res = await pending
|
|
192
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
193
|
+
return res.result
|
|
194
|
+
} finally { client.close() }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Verifica que un `vault.admin.event` (entró o salió alguien del perfil) viene
|
|
199
|
+
* FIRMADO por la maestra pineada. Un aviso sin firma no se muestra: si no, cualquiera
|
|
200
|
+
* podría llenar de alarmas falsas los dispositivos del usuario.
|
|
201
|
+
*/
|
|
202
|
+
export async function verifyAdminEvent ({ body, signature, master }) {
|
|
203
|
+
if (!body || typeof body.ev !== 'string') return false
|
|
204
|
+
return verifyDeviceSig({ publickey: master, data: body, signature })
|
|
205
|
+
}
|
|
206
|
+
|
|
149
207
|
export { identifyAsDevice }
|
package/src/ctl.js
CHANGED
|
@@ -264,7 +264,7 @@ async function cmdMembers () {
|
|
|
264
264
|
if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
265
265
|
if (!acta.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
266
266
|
|
|
267
|
-
const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves' }
|
|
267
|
+
const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
|
|
268
268
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
269
269
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
270
270
|
// misma que se enseña al emparejar y en la lista de miembros.
|
|
@@ -280,7 +280,9 @@ async function cmdMembers () {
|
|
|
280
280
|
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
|
|
281
281
|
console.log(' %s %s%s\n %s', m.id, quien, marcas.length ? ' [' + marcas.join(' · ') + ']' : '', caps)
|
|
282
282
|
}
|
|
283
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee')
|
|
283
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
|
|
284
|
+
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
285
|
+
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
284
286
|
console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
|
|
285
287
|
}
|
|
286
288
|
|
|
@@ -288,10 +290,13 @@ async function cmdMembers () {
|
|
|
288
290
|
async function cmdCaps (args = []) {
|
|
289
291
|
const [id, ...cambios] = args
|
|
290
292
|
if (!id || !cambios.length) {
|
|
291
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee')
|
|
293
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
|
|
292
294
|
process.exit(2)
|
|
293
295
|
}
|
|
294
|
-
const NOMBRE = {
|
|
296
|
+
const NOMBRE = {
|
|
297
|
+
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
|
|
298
|
+
sign: 'sign', store: 'store', read: 'read', admin: 'admin'
|
|
299
|
+
}
|
|
295
300
|
const s = requireDaemon()
|
|
296
301
|
const actaFile = path.join(dataDir(), 'acta.json')
|
|
297
302
|
try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
|
|
@@ -580,7 +585,7 @@ function help () {
|
|
|
580
585
|
reject <deviceId> rechaza un dispositivo pendiente
|
|
581
586
|
devices lista dispositivos enrolados / revocados
|
|
582
587
|
members el acta del perfil: quién es tuyo y qué puede hacer
|
|
583
|
-
caps <ID> ±permiso cambia permisos (+firma -guarda …)
|
|
588
|
+
caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
|
|
584
589
|
revoke <nonce> revoca un dispositivo (le ordena autoborrarse)
|
|
585
590
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
586
591
|
logs últimos logs del servicio
|
package/src/daemon.js
CHANGED
|
@@ -42,10 +42,10 @@ function comprobarInstanciaUnica (dir) {
|
|
|
42
42
|
const pid = Number(s?.pid)
|
|
43
43
|
if (!pid || pid === process.pid) return
|
|
44
44
|
try { process.kill(pid, 0) } catch (_) { return } // no existe: el candado es de un muerto
|
|
45
|
-
console.error('
|
|
46
|
-
console.error('
|
|
47
|
-
console.error('
|
|
48
|
-
console.error('DOTRINO_VAULT_DIR
|
|
45
|
+
console.error('A vault is already running on this data (process %d).', pid)
|
|
46
|
+
console.error(' data: %s', dir)
|
|
47
|
+
console.error('Two vaults on the same directory step on each other: stop the other one,')
|
|
48
|
+
console.error('or use DOTRINO_VAULT_DIR to give this one its own directory.')
|
|
49
49
|
process.exit(3)
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -91,7 +91,7 @@ export async function runDaemon () {
|
|
|
91
91
|
try {
|
|
92
92
|
const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
|
|
93
93
|
return { id, vault: mgr.get(id) }
|
|
94
|
-
} catch (e) { console.error('[vault]
|
|
94
|
+
} catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
|
|
95
95
|
}
|
|
96
96
|
const targetOf = (req) => resolveTarget(req)?.vault || null
|
|
97
97
|
|
|
@@ -139,7 +139,7 @@ export async function runDaemon () {
|
|
|
139
139
|
const cur = readJsonSafe(pairFile)
|
|
140
140
|
if (cur?.qr?.token === tok) rm(pairFile)
|
|
141
141
|
}, expiresInMs + 1000).unref?.()
|
|
142
|
-
console.log('[vault]
|
|
142
|
+
console.log('[vault] pairing started (valid for %d min)', expiresInMs / 60000)
|
|
143
143
|
} catch (e) {
|
|
144
144
|
console.error('[vault] no se pudo iniciar emparejamiento:', e.message)
|
|
145
145
|
}
|
|
@@ -175,7 +175,7 @@ export async function runDaemon () {
|
|
|
175
175
|
case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
|
|
176
176
|
case 'password-set': { await mgr.profiles.setPassword(ref(), req.password); return { done: 'contraseña guardada' } }
|
|
177
177
|
case 'password-rm': { mgr.profiles.removePassword(ref()); return { done: 'contraseña quitada' } }
|
|
178
|
-
default: throw new Error('
|
|
178
|
+
default: throw new Error('unknown profile operation: ' + req.op)
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
@@ -186,7 +186,7 @@ export async function runDaemon () {
|
|
|
186
186
|
try {
|
|
187
187
|
const vault = targetOf(appr)
|
|
188
188
|
const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile); console.log('[vault] aprobado %s', r.deviceId)
|
|
189
|
-
} catch (e) { console.error('[vault]
|
|
189
|
+
} catch (e) { console.error('[vault] approval failed:', e.message) }
|
|
190
190
|
rm(approveReqFile)
|
|
191
191
|
}
|
|
192
192
|
const rej = readJsonSafe(rejectReqFile)
|
|
@@ -200,13 +200,13 @@ export async function runDaemon () {
|
|
|
200
200
|
rm(path.join(dir, 'caps-request.json'))
|
|
201
201
|
try {
|
|
202
202
|
await targetOf(capsReq)?.setCaps(capsReq.pub, capsReq.caps)
|
|
203
|
-
console.log('[vault]
|
|
204
|
-
} catch (e) { console.error('[vault]
|
|
203
|
+
console.log('[vault] permissions updated: %s', capsReq.caps.join(', ') || '(ninguno)')
|
|
204
|
+
} catch (e) { console.error('[vault] could not change permissions:', e.message) }
|
|
205
205
|
}
|
|
206
206
|
const req = readJsonSafe(revokeReqFile)
|
|
207
207
|
if (req?.nonce) {
|
|
208
|
-
try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault]
|
|
209
|
-
catch (e) { console.error('[vault]
|
|
208
|
+
try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault] revoked nonce=%s', req.nonce) }
|
|
209
|
+
catch (e) { console.error('[vault] revocation failed:', e.message) }
|
|
210
210
|
rm(revokeReqFile)
|
|
211
211
|
}
|
|
212
212
|
// Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
|
|
@@ -216,9 +216,9 @@ export async function runDaemon () {
|
|
|
216
216
|
rm(secretReqFile)
|
|
217
217
|
try {
|
|
218
218
|
const vault = targetOf(sec)
|
|
219
|
-
if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault]
|
|
220
|
-
else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault]
|
|
221
|
-
} catch (e) { console.error('[vault]
|
|
219
|
+
if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
|
|
220
|
+
else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
221
|
+
} catch (e) { console.error('[vault] secret failed:', e.message) }
|
|
222
222
|
}
|
|
223
223
|
// Perfiles / candado.
|
|
224
224
|
const preq = readJsonSafe(profileReqFile)
|
|
@@ -228,7 +228,7 @@ export async function runDaemon () {
|
|
|
228
228
|
try { extra = await handleProfileRequest(preq) }
|
|
229
229
|
// `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
|
|
230
230
|
// que leerse en el idioma de quien lo lee, no en el del daemon).
|
|
231
|
-
catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault]
|
|
231
|
+
catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] profile: %s', e.message) }
|
|
232
232
|
dumpProfiles(extra)
|
|
233
233
|
} else {
|
|
234
234
|
dumpProfiles()
|
|
@@ -243,7 +243,7 @@ export async function runDaemon () {
|
|
|
243
243
|
// Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
|
|
244
244
|
try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
|
|
245
245
|
} catch (e) {
|
|
246
|
-
console.error('[vault] error
|
|
246
|
+
console.error('[vault] error handling a control signal:', e.message)
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
@@ -272,14 +272,14 @@ export async function runDaemon () {
|
|
|
272
272
|
// atiende cuando el archivo YA parsea; si no, lo recoge el repaso de 2 s.
|
|
273
273
|
if (readJsonSafe(pairReqFile)) await atenderEmparejamiento()
|
|
274
274
|
await atenderPeticiones()
|
|
275
|
-
} catch (e) { console.error('[vault] error
|
|
275
|
+
} catch (e) { console.error('[vault] error serving a request:', e.message) }
|
|
276
276
|
finally { atendiendo = false }
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
try {
|
|
280
280
|
fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) atender() })
|
|
281
281
|
} catch (e) {
|
|
282
|
-
console.error('[vault]
|
|
282
|
+
console.error('[vault] could not watch %s (%s); will be served by polling only', dir, e.message)
|
|
283
283
|
}
|
|
284
284
|
const repaso = setInterval(atender, REPASO_MS)
|
|
285
285
|
repaso.unref?.()
|
package/src/manager.js
CHANGED
|
@@ -72,7 +72,7 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
72
72
|
|
|
73
73
|
const get = (id) => {
|
|
74
74
|
const v = running.get(id)
|
|
75
|
-
if (!v) throw new Error('
|
|
75
|
+
if (!v) throw new Error('profile is not open: ' + id)
|
|
76
76
|
return v
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -120,7 +120,7 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
120
120
|
])
|
|
121
121
|
assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
|
|
122
122
|
} else {
|
|
123
|
-
log('[vault]
|
|
123
|
+
log('[vault] profile %s is not open: deleting without being able to check its record', id)
|
|
124
124
|
}
|
|
125
125
|
const res = profiles.remove(id) // valida: no es el único, no está bloqueado
|
|
126
126
|
try { running.get(id)?.close() } catch (_) {}
|