@dotrino/vaultd 0.12.0 → 0.14.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 +74 -5
- package/src/daemon.js +53 -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 +204 -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.14.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
|
@@ -249,6 +249,68 @@ function cmdReject (deviceId) {
|
|
|
249
249
|
console.log('Rechazado %s.', deviceId)
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* `dotrino-vault me` — el PERFIL del usuario tal como lo tiene la bóveda: apodo, foto y
|
|
254
|
+
* datos. Es lo que editas en cualquier dispositivo emparejado y se sincroniza aquí, así
|
|
255
|
+
* que sirve para comprobar que lo que cambiaste en el aparato llegó de verdad.
|
|
256
|
+
*
|
|
257
|
+
* Distinto de `members` (quién es del perfil) y de `profile` (los perfiles del PC): esto
|
|
258
|
+
* es el CONTENIDO, no la identidad.
|
|
259
|
+
*
|
|
260
|
+
* La foto no se imprime —es un data-URI de hasta ~90 KB— sino que se resume;
|
|
261
|
+
* `me --foto <archivo>` la escribe en disco para poder mirarla.
|
|
262
|
+
*/
|
|
263
|
+
async function cmdMe (args = []) {
|
|
264
|
+
const i = args.findIndex((a) => a === '--foto' || a === '--photo')
|
|
265
|
+
const avatarPath = i >= 0 ? args[i + 1] : null
|
|
266
|
+
if (i >= 0 && !avatarPath) { console.error('uso: dotrino-vault me --foto <archivo>'); process.exit(2) }
|
|
267
|
+
|
|
268
|
+
const s = requireDaemon()
|
|
269
|
+
const meFile = path.join(dir, 'me.json')
|
|
270
|
+
try { fs.rmSync(meFile, { force: true }) } catch (_) {}
|
|
271
|
+
writeReq('me-request.json', { ...(avatarPath ? { avatarPath: path.resolve(avatarPath) } : {}) })
|
|
272
|
+
avisar(s.pid, 'SIGUSR2')
|
|
273
|
+
let dump = null
|
|
274
|
+
for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
|
|
275
|
+
// El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
|
|
276
|
+
try { fs.rmSync(meFile, { force: true }) } catch (_) {}
|
|
277
|
+
if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
|
|
278
|
+
|
|
279
|
+
const me = dump.me
|
|
280
|
+
if (!me) {
|
|
281
|
+
console.log('\nTodavía no hay perfil en esta bóveda.')
|
|
282
|
+
console.log('Edita tu nombre o tu foto en un dispositivo emparejado y vuelve a mirar.\n')
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const cuando = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
|
|
287
|
+
console.log('\n%sPerfil%s · actualizado %s\n', B, Z, cuando)
|
|
288
|
+
console.log(' nombre : %s', me.nickname || '(sin nombre)')
|
|
289
|
+
console.log(' foto : %s', me.avatar
|
|
290
|
+
? `sí · ${me.avatar.type || 'desconocido'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
|
|
291
|
+
: 'no')
|
|
292
|
+
|
|
293
|
+
// Los campos estándar. `visible` es del usuario: teléfono y dirección nacen ocultos.
|
|
294
|
+
const STD = [['nombres', 'nombres'], ['apellidos', 'apellidos'], ['email', 'correo'],
|
|
295
|
+
['telefono', 'teléfono'], ['direccion', 'dirección']]
|
|
296
|
+
const puestos = STD.filter(([k]) => me[k])
|
|
297
|
+
if (puestos.length) {
|
|
298
|
+
console.log('')
|
|
299
|
+
for (const [k, etiqueta] of puestos) {
|
|
300
|
+
console.log(' %s: %s%s', etiqueta.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const [titulo, lista] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
|
|
304
|
+
if (!Array.isArray(lista) || !lista.length) continue
|
|
305
|
+
console.log('\n %s:', titulo)
|
|
306
|
+
for (const x of lista) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (dump.avatarGuardada) console.log('\n Foto escrita en: %s', dump.avatarGuardada)
|
|
310
|
+
else if (me.avatar) console.log('\n Para verla: dotrino-vault me --foto ~/perfil.png')
|
|
311
|
+
console.log('')
|
|
312
|
+
}
|
|
313
|
+
|
|
252
314
|
/**
|
|
253
315
|
* `dotrino-vault members` — el ACTA del perfil: qué llaves son tuyas y qué puede hacer cada
|
|
254
316
|
* una. Es la misma información que muestra la consola de vault.dotrino.com.
|
|
@@ -264,7 +326,7 @@ async function cmdMembers () {
|
|
|
264
326
|
if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
265
327
|
if (!acta.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
266
328
|
|
|
267
|
-
const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves' }
|
|
329
|
+
const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
|
|
268
330
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
269
331
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
270
332
|
// misma que se enseña al emparejar y en la lista de miembros.
|
|
@@ -280,7 +342,9 @@ async function cmdMembers () {
|
|
|
280
342
|
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
|
|
281
343
|
console.log(' %s %s%s\n %s', m.id, quien, marcas.length ? ' [' + marcas.join(' · ') + ']' : '', caps)
|
|
282
344
|
}
|
|
283
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee')
|
|
345
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
|
|
346
|
+
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
347
|
+
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
284
348
|
console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
|
|
285
349
|
}
|
|
286
350
|
|
|
@@ -288,10 +352,13 @@ async function cmdMembers () {
|
|
|
288
352
|
async function cmdCaps (args = []) {
|
|
289
353
|
const [id, ...cambios] = args
|
|
290
354
|
if (!id || !cambios.length) {
|
|
291
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee')
|
|
355
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
|
|
292
356
|
process.exit(2)
|
|
293
357
|
}
|
|
294
|
-
const NOMBRE = {
|
|
358
|
+
const NOMBRE = {
|
|
359
|
+
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
|
|
360
|
+
sign: 'sign', store: 'store', read: 'read', admin: 'admin'
|
|
361
|
+
}
|
|
295
362
|
const s = requireDaemon()
|
|
296
363
|
const actaFile = path.join(dataDir(), 'acta.json')
|
|
297
364
|
try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
|
|
@@ -579,8 +646,9 @@ function help () {
|
|
|
579
646
|
approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
|
|
580
647
|
reject <deviceId> rechaza un dispositivo pendiente
|
|
581
648
|
devices lista dispositivos enrolados / revocados
|
|
649
|
+
me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
|
|
582
650
|
members el acta del perfil: quién es tuyo y qué puede hacer
|
|
583
|
-
caps <ID> ±permiso cambia permisos (+firma -guarda …)
|
|
651
|
+
caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
|
|
584
652
|
revoke <nonce> revoca un dispositivo (le ordena autoborrarse)
|
|
585
653
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
586
654
|
logs últimos logs del servicio
|
|
@@ -623,6 +691,7 @@ export async function runCtl (argv) {
|
|
|
623
691
|
case 'approve': return cmdApprove(rest[0])
|
|
624
692
|
case 'reject': return cmdReject(rest[0])
|
|
625
693
|
case 'devices': return cmdDevices()
|
|
694
|
+
case 'me': return cmdMe(rest)
|
|
626
695
|
case 'members': return cmdMembers()
|
|
627
696
|
case 'caps': return cmdCaps(rest)
|
|
628
697
|
case 'revoke': return cmdRevoke(rest[0])
|