@dotrino/vaultd 0.26.2 → 0.46.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +92 -3
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +38 -25
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +22 -0
- package/lib/src/service.js +497 -135
- package/package.json +11 -6
- package/src/ctl.js +639 -98
- package/src/daemon.js +321 -52
- package/src/manager.js +12 -6
- package/src/profiles.js +109 -13
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +881 -29
- package/src/store.js +3 -1
- package/src/transport.js +2 -2
- package/src/tui/app.js +625 -129
- package/src/tui/i18n.js +145 -24
- package/src/vault.js +972 -48
- package/src/vaultControl.js +286 -61
package/lib/src/service.js
CHANGED
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import fs from 'node:fs'
|
|
22
22
|
import path from 'node:path'
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
23
24
|
import {
|
|
24
|
-
makeDeviceKey, signWithDevice, verifyDelegation,
|
|
25
|
-
makePairingCode, commitCode, pubkeyId
|
|
25
|
+
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
|
+
verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
26
27
|
} from '@dotrino/identity/capabilities'
|
|
28
|
+
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
29
|
+
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
27
30
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
28
31
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
29
32
|
import { parseInvite } from './invite.js'
|
|
@@ -58,7 +61,7 @@ function installNodeGlobals () {
|
|
|
58
61
|
})
|
|
59
62
|
}
|
|
60
63
|
if (typeof globalThis.WebSocket === 'undefined') {
|
|
61
|
-
throw new Error('
|
|
64
|
+
throw new Error('this runtime has no global WebSocket: use Node >=22')
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -70,17 +73,17 @@ function installNodeGlobals () {
|
|
|
70
73
|
* que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
|
|
71
74
|
* aprende la bóveda donde tú lo tecleas.
|
|
72
75
|
*/
|
|
73
|
-
async function
|
|
76
|
+
async function verifyHello (p, sn) {
|
|
74
77
|
const b = p?.body
|
|
75
|
-
if (!b?.iss || b.sn !== sn) throw new Error('
|
|
78
|
+
if (!b?.iss || b.sn !== sn) throw new Error('the vault answered a different pairing')
|
|
76
79
|
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
77
|
-
throw new Error('
|
|
80
|
+
throw new Error('the vault reply is not properly signed')
|
|
78
81
|
}
|
|
79
82
|
// El modo también viene aquí, y aquí viene FIRMADO por la bóveda. Se comprueba
|
|
80
83
|
// de nuevo aunque ya se haya mirado el del QR: en la forma corta el QR es un
|
|
81
84
|
// código que pasó por manos ajenas, y esta es la primera vez que la bóveda
|
|
82
85
|
// dice de su puño y letra qué se propone hacer.
|
|
83
|
-
|
|
86
|
+
rejectAdoption(b.m)
|
|
84
87
|
return b
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -98,11 +101,11 @@ async function verificarHola (p, sn) {
|
|
|
98
101
|
* invitación, en vez de dejar que el viaje termine en un «intent-mismatch» del
|
|
99
102
|
* otro lado que no le explica nada a nadie.
|
|
100
103
|
*/
|
|
101
|
-
function
|
|
102
|
-
if (
|
|
104
|
+
function rejectAdoption (mode) {
|
|
105
|
+
if (mode !== 'adopt') return
|
|
103
106
|
throw new Error(
|
|
104
|
-
'
|
|
105
|
-
'
|
|
107
|
+
'this invitation was opened to ADOPT the device account, and an agent does not transfer its identity: ' +
|
|
108
|
+
'the vault grants it one. Open the pairing without `--adopt` (`dotrino-vault pair --service <ns>`).'
|
|
106
109
|
)
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -113,14 +116,14 @@ function rechazarAdopcion (modo) {
|
|
|
113
116
|
* siempre significa lo mismo para quien lo lee: el código ya se usó o venció, y
|
|
114
117
|
* hay que pedir otro en la bóveda. Se dice así, no con el error crudo.
|
|
115
118
|
*/
|
|
116
|
-
async function
|
|
117
|
-
if (!code) throw new Error('
|
|
119
|
+
async function resolveAppointment (client, code) {
|
|
120
|
+
if (!code) throw new Error('the invitation carries no pairing code')
|
|
118
121
|
if (typeof client.redeemPairingCode !== 'function') {
|
|
119
|
-
throw new Error('
|
|
122
|
+
throw new Error('this proxy does not support pairing codes (update @dotrino/proxy-client)')
|
|
120
123
|
}
|
|
121
124
|
const r = await client.redeemPairingCode(code)
|
|
122
125
|
if (!r?.ok || !r.instance) {
|
|
123
|
-
throw new Error(`
|
|
126
|
+
throw new Error(`that code is no good: ${r?.error || 'not valid'}. Ask the vault for a new one.`)
|
|
124
127
|
}
|
|
125
128
|
return r.instance
|
|
126
129
|
}
|
|
@@ -135,7 +138,7 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
|
135
138
|
// para siempre y waitForSecrets no reintentaría. Le ponemos un timeout propio.
|
|
136
139
|
let timer
|
|
137
140
|
const timeout = new Promise((_, reject) => {
|
|
138
|
-
timer = setTimeout(() => reject(new Error('timeout
|
|
141
|
+
timer = setTimeout(() => reject(new Error('timeout connecting to the proxy')), connectTimeoutMs)
|
|
139
142
|
})
|
|
140
143
|
try {
|
|
141
144
|
await Promise.race([client.connect(), timeout])
|
|
@@ -143,8 +146,8 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
|
143
146
|
try { client.close() } catch (_) {}
|
|
144
147
|
// El 'error' de transporte del cliente puede llegar como un Event sin
|
|
145
148
|
// `message` → sin esto el operador ve una línea de error vacía.
|
|
146
|
-
const why = e?.message || e?.type || 'error
|
|
147
|
-
throw new Error(`
|
|
149
|
+
const why = e?.message || e?.type || 'transport error'
|
|
150
|
+
throw new Error(`could not connect to the proxy ${proxyUrl}: ${why}`)
|
|
148
151
|
} finally {
|
|
149
152
|
clearTimeout(timer)
|
|
150
153
|
}
|
|
@@ -163,7 +166,7 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
|
163
166
|
const off = client.on('message', (_from, payload) => {
|
|
164
167
|
if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
|
|
165
168
|
})
|
|
166
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
169
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for the vault reply')) }, timeoutMs)
|
|
167
170
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
168
171
|
})
|
|
169
172
|
}
|
|
@@ -221,100 +224,180 @@ function writeServiceIdentity (dir, obj) {
|
|
|
221
224
|
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
222
225
|
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
223
226
|
*/
|
|
224
|
-
|
|
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 } = {}) {
|
|
225
244
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
226
|
-
// `dotrino-vault pair
|
|
227
|
-
//
|
|
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.
|
|
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.
|
|
232
247
|
if (typeof qr === 'string') {
|
|
233
248
|
const o = parseInvite(qr)
|
|
234
|
-
if (!o) throw new Error('
|
|
249
|
+
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair`)')
|
|
235
250
|
qr = o
|
|
236
251
|
}
|
|
237
|
-
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr
|
|
238
|
-
|
|
239
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
|
|
240
|
-
if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
|
|
241
|
-
label = label || 'servicio:' + ns
|
|
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
|
-
}
|
|
252
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
|
|
253
|
+
rejectAdoption(qr.m)
|
|
257
254
|
|
|
258
255
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
259
256
|
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
260
257
|
if (!qr.iss) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
264
|
-
// aunque la bóveda esté en otro proxio de la malla.
|
|
265
|
-
const destino = await resolverCita(client, qr.conn)
|
|
266
|
-
const hola = await new Promise((resolve, reject) => {
|
|
258
|
+
const target = await resolveAppointment(client, qr.conn)
|
|
259
|
+
const hello = await new Promise((resolve, reject) => {
|
|
267
260
|
const off = client.on('message', (_f, p) => {
|
|
268
|
-
if (p?.type === MSG.HELLO_OK) {
|
|
269
|
-
else if (p?.type === MSG.ERROR) {
|
|
261
|
+
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
262
|
+
else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
|
|
270
263
|
})
|
|
271
|
-
const t = setTimeout(() => {
|
|
272
|
-
const
|
|
273
|
-
try { client.send(
|
|
264
|
+
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
265
|
+
const finish = () => { off(); clearTimeout(t) }
|
|
266
|
+
try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
|
|
274
267
|
})
|
|
275
|
-
qr = { ...qr, iss:
|
|
268
|
+
qr = { ...qr, iss: hello.iss, proxy: hello.proxy || qr.proxy }
|
|
276
269
|
}
|
|
277
270
|
try {
|
|
278
271
|
const device = await makeDeviceKey({ label })
|
|
272
|
+
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
273
|
+
// aparato entra al acta pero no le llega ningún secreto, y no da error.
|
|
274
|
+
const enc = await makeDeviceEncKey()
|
|
279
275
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
280
|
-
// Código ALEATORIO
|
|
281
|
-
//
|
|
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.
|
|
282
278
|
const code = makePairingCode()
|
|
283
|
-
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
284
|
-
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
285
279
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
286
|
-
|
|
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() }
|
|
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() }
|
|
291
281
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
292
282
|
|
|
293
283
|
const enrolled = new Promise((resolve, reject) => {
|
|
294
284
|
const off = client.on('message', (_from, p) => {
|
|
295
285
|
if (!p || typeof p !== 'object') return
|
|
296
286
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
297
|
-
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}`))
|
|
298
288
|
show({ deviceId, code })
|
|
299
289
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
300
290
|
})
|
|
301
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
291
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
|
|
302
292
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
303
293
|
})
|
|
304
294
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
305
295
|
const res = await enrolled
|
|
306
296
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
297
|
+
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
298
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
299
|
+
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
300
|
+
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
301
|
+
|
|
302
|
+
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
303
|
+
} finally { client.close() }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Enrola ESTE servicio contra el vault y persiste su identidad.
|
|
308
|
+
*
|
|
309
|
+
* UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
|
|
310
|
+
* aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
|
|
311
|
+
* adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
|
|
312
|
+
* el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
|
|
313
|
+
* había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
|
|
314
|
+
* es un servicio, no una persona; no tiene por qué "ser varios".
|
|
315
|
+
*
|
|
316
|
+
* Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
|
|
317
|
+
* es además la forma de rotar la identidad de un agente comprometido. Lo que sí
|
|
318
|
+
* hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
|
|
319
|
+
*
|
|
320
|
+
* En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
|
|
321
|
+
* que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
|
|
322
|
+
* Muestra un código por `onCode`: el dueño lo tipea en el vault
|
|
323
|
+
* (`dotrino-vault approve <código>`).
|
|
324
|
+
*
|
|
325
|
+
* @param {Object} opts
|
|
326
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
327
|
+
* @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
|
|
328
|
+
* @param {string} opts.dir Dónde persistir `service-identity.json`.
|
|
329
|
+
* @param {string} [opts.label]
|
|
330
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
331
|
+
* @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
|
|
332
|
+
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
333
|
+
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
334
|
+
*/
|
|
335
|
+
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
336
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
337
|
+
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
338
|
+
label = label || 'service:' + ns
|
|
339
|
+
|
|
340
|
+
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
341
|
+
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
342
|
+
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
343
|
+
// que se re-pineen a mano.
|
|
344
|
+
const previous = readServiceIdentity(dir)
|
|
345
|
+
let replaced = null
|
|
346
|
+
if (previous?.device?.publickey) {
|
|
347
|
+
replaced = {
|
|
348
|
+
ns: previous.ns,
|
|
349
|
+
enrolledAt: previous.enrolledAt,
|
|
350
|
+
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
351
|
+
}
|
|
352
|
+
try { onReplace?.(replaced) } catch (_) {}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { device, enc, cert, iss, proxy } = await enrollWithVault({ qr, label, expectedScope: secretsScope(ns), onCode, approveTimeoutMs })
|
|
356
|
+
// v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
|
|
357
|
+
// id de nodo del proxio y la fila del acta.
|
|
358
|
+
writeServiceIdentity(dir, { v: 2, ns, iss, proxy, device, enc, cert, enrolledAt: Date.now() })
|
|
359
|
+
return { device, enc, cert, iss, replaced }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function ensureEncKey ({ dir } = {}) {
|
|
363
|
+
const saved = readServiceIdentity(dir)
|
|
364
|
+
if (!saved) throw new Error('service not enrolled: run enrollService() first')
|
|
365
|
+
if (saved.enc?.publickey && saved.enc?.privateJwk) return { encPub: saved.enc.publickey, created: false }
|
|
366
|
+
const enc = await makeDeviceEncKey()
|
|
367
|
+
writeServiceIdentity(dir, { ...saved, v: 2, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } })
|
|
368
|
+
return { encPub: enc.encPublickey, created: true }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Registra en la bóveda la llave de cifrado de ESTE servicio, para que pueda sellarle
|
|
373
|
+
* sus variables. Genera la llave si falta.
|
|
374
|
+
*
|
|
375
|
+
* Va por `MSG.SECRETS` con `op:'enckey'` a propósito: no hace falta una constante nueva
|
|
376
|
+
* del protocolo, y así el trío de archivos vendorizado en el iframe de identidad no se
|
|
377
|
+
* mueve. Registrar una llave no da acceso a nada por sí solo —quien firma esta petición
|
|
378
|
+
* ya tiene la llave de firma del servicio, o sea ya lee ese namespace—, así que no exige
|
|
379
|
+
* la contraseña del perfil.
|
|
380
|
+
*/
|
|
381
|
+
export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
382
|
+
const saved = readServiceIdentity(dir)
|
|
383
|
+
const { encPub, created } = await ensureEncKey({ dir })
|
|
384
|
+
ns = ns || saved?.ns
|
|
385
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
386
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
387
|
+
device = device || saved?.device
|
|
388
|
+
cert = cert || saved?.cert
|
|
389
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
390
|
+
|
|
391
|
+
const client = await freshClient(proxyUrl)
|
|
392
|
+
try {
|
|
393
|
+
await identifyAsService(client, device)
|
|
394
|
+
const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
|
|
395
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
396
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
397
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
398
|
+
const res = await pending
|
|
399
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
400
|
+
return { encPub, created, ok: true }
|
|
318
401
|
} finally { client.close() }
|
|
319
402
|
}
|
|
320
403
|
|
|
@@ -324,17 +407,21 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
324
407
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
325
408
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
326
409
|
*/
|
|
327
|
-
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 } = {}) {
|
|
328
411
|
let saved = null
|
|
329
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 }
|
|
330
417
|
ns = ns || saved?.ns
|
|
331
418
|
proxyUrl = proxyUrl || saved?.proxy
|
|
332
419
|
masterPubkey = masterPubkey || saved?.iss
|
|
333
420
|
device = device || saved?.device
|
|
334
421
|
cert = cert || saved?.cert
|
|
335
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
422
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
|
|
336
423
|
if (!proxyUrl || !masterPubkey || !device || !cert) {
|
|
337
|
-
throw new Error('
|
|
424
|
+
throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
338
425
|
}
|
|
339
426
|
|
|
340
427
|
const client = await freshClient(proxyUrl)
|
|
@@ -366,17 +453,116 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
366
453
|
|
|
367
454
|
// Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
|
|
368
455
|
const body = res.body
|
|
369
|
-
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('
|
|
370
|
-
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('
|
|
456
|
+
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
457
|
+
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
|
|
371
458
|
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
|
|
372
|
-
if (!ok) throw new Error('
|
|
459
|
+
if (!ok) throw new Error('invalid master signature on the secrets reply')
|
|
373
460
|
|
|
374
461
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
375
|
-
|
|
462
|
+
|
|
463
|
+
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
464
|
+
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
465
|
+
// ni los nombres de tus variables;
|
|
466
|
+
// · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
|
|
467
|
+
// sin poder abrirlo.
|
|
468
|
+
// Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
|
|
469
|
+
if (payload?.sealed) return openSealedBundle(payload.sealed, saved, payload.acta, masterPubkey)
|
|
470
|
+
|
|
471
|
+
// Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
|
|
472
|
+
// cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
|
|
473
|
+
if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
|
|
376
474
|
return payload.secrets
|
|
377
475
|
} finally { client.close() }
|
|
378
476
|
}
|
|
379
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
480
|
+
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
|
481
|
+
*
|
|
482
|
+
* Un fallo al abrir es un ERROR DURO, nunca un salto a lo del scope ni un valor
|
|
483
|
+
* omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
|
|
484
|
+
* con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
|
|
485
|
+
*/
|
|
486
|
+
/**
|
|
487
|
+
* ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
|
|
488
|
+
*
|
|
489
|
+
* Envolver una llave solo necesita públicas, así que **cualquiera puede fabricar un sobre
|
|
490
|
+
* válido** para este servicio: abrirlo prueba que es para mí, no que lo escribió quien
|
|
491
|
+
* debía. Lo que lo prueba es la firma, hecha con la llave de sellado que el acta nombra
|
|
492
|
+
* para el `seq` con el que se firmó — y el acta la firma la maestra, que es la que este
|
|
493
|
+
* agente lleva pineada desde que se enroló.
|
|
494
|
+
*
|
|
495
|
+
* Una firma que NO cuadra es un error duro: es exactamente el caso que esto viene a
|
|
496
|
+
* cazar. Un sobre SIN firma se acepta y se avisa: los hay de antes de que esto existiera
|
|
497
|
+
* y negarse a arrancar por eso apagaría servicios que llevan meses bien.
|
|
498
|
+
*/
|
|
499
|
+
export async function makeSealCheck (acta, masterPubkey, log = console.log) {
|
|
500
|
+
if (!acta) return () => {}
|
|
501
|
+
// El acta tiene que venir firmada por la maestra que este agente ya conoce. Si la
|
|
502
|
+
// selló otro (un traspaso que este agente no ha visto), no se puede establecer
|
|
503
|
+
// procedencia: se dice y se sigue, en vez de fingir que se comprobó.
|
|
504
|
+
const ok = acta.sealedBy === masterPubkey && (await verifyActa({ acta })).ok
|
|
505
|
+
if (!ok) {
|
|
506
|
+
log('[vault] ⚠ the record does not come from the master this agent knows: envelope provenance NOT checked')
|
|
507
|
+
return () => {}
|
|
508
|
+
}
|
|
509
|
+
let avisado = false
|
|
510
|
+
return async (owner, key, gen, e, seal) => {
|
|
511
|
+
if (!seal?.sig) {
|
|
512
|
+
if (!avisado) { avisado = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
const pub = sealKeyAt(acta, seal.seq)
|
|
516
|
+
if (!pub) throw new Error(`${key}: the record has no sealing key for #${seal.seq} (the envelope claims a record that does not exist)`)
|
|
517
|
+
const good = await verifyDeviceSig({ publickey: pub, data: { owner, key, gen, iv: e.iv, ct: e.ct }, signature: seal.sig })
|
|
518
|
+
if (!good) throw new Error(`${key}: the envelope signature does not check out — it did not come from this vault`)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null) {
|
|
523
|
+
if (!ident?.enc?.privateJwk) {
|
|
524
|
+
throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
|
|
525
|
+
}
|
|
526
|
+
const mine = await importDeviceEncKey(ident.enc.privateJwk)
|
|
527
|
+
|
|
528
|
+
// UNA LLAVE POR GENERACIÓN, no una por cajón. Desde v5 cada escritura estrena
|
|
529
|
+
// generación —la bóveda no puede reutilizar una llave que no puede abrir—, así que dos
|
|
530
|
+
// variables del mismo cajón pueden venir de generaciones distintas. El bundle trae
|
|
531
|
+
// TODAS las envolturas de este aparato; se abren perezosamente, solo las que hagan
|
|
532
|
+
// falta. `sealed.ns`/`sealed.dev` (una sola, la vigente) siguen entrando: es el bundle
|
|
533
|
+
// de v4 y sirve para lo que ese vault selló.
|
|
534
|
+
const porGen = { ns: new Map(), dev: new Map() }
|
|
535
|
+
const añade = (cual, info) => { if (info?.wrap) porGen[cual].set(info.gen ?? 0, info.wrap) }
|
|
536
|
+
añade('ns', sealed.ns); añade('dev', sealed.dev)
|
|
537
|
+
for (const cual of ['ns', 'dev']) for (const info of sealed.wraps?.[cual] || []) añade(cual, info)
|
|
538
|
+
|
|
539
|
+
const abiertas = { ns: new Map(), dev: new Map() }
|
|
540
|
+
const cekDe = async (cual, gen) => {
|
|
541
|
+
if (abiertas[cual].has(gen)) return abiertas[cual].get(gen)
|
|
542
|
+
// Un bundle de v4 no traía `gen` en la envoltura: si solo hay una, es esa.
|
|
543
|
+
const wrap = porGen[cual].get(gen) ?? (porGen[cual].size === 1 ? [...porGen[cual].values()][0] : null)
|
|
544
|
+
if (!wrap) return null
|
|
545
|
+
const cek = await openWrap({ wrap, myEncPrivateKey: mine })
|
|
546
|
+
abiertas[cual].set(gen, cek)
|
|
547
|
+
return cek
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const comprobarFirma = await makeSealCheck(acta, masterPubkey)
|
|
551
|
+
|
|
552
|
+
const out = {}
|
|
553
|
+
for (const [key, e] of Object.entries(sealed.entries || {})) {
|
|
554
|
+
if (e.pub) { out[key] = e.v; continue }
|
|
555
|
+
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
556
|
+
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
557
|
+
const gen = e.gen ?? e.e?.gen ?? 0
|
|
558
|
+
await comprobarFirma(e.owner, key, gen, e.e, e.seal)
|
|
559
|
+
const cek = await cekDe(cual, gen)
|
|
560
|
+
if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
|
|
561
|
+
out[key] = await decryptWithCek({ cek, envelope: e.e })
|
|
562
|
+
}
|
|
563
|
+
return out
|
|
564
|
+
}
|
|
565
|
+
|
|
380
566
|
/**
|
|
381
567
|
* Escucha los avisos de cambio de configuración de la bóveda.
|
|
382
568
|
*
|
|
@@ -388,6 +574,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
388
574
|
* Lo que NO hace: recargar nada. El aviso no trae valores, y la reacción correcta
|
|
389
575
|
* es que el proceso termine y lo levante su supervisor (ver `watchEnv`).
|
|
390
576
|
*
|
|
577
|
+
* NO SE CONFÍA SOLO EN EL AVISO: al (re)conectar, COMPARA. Un aviso es un mensaje y
|
|
578
|
+
* los mensajes se pierden — el agente pudo estar vivo pero incomunicado, y entonces
|
|
579
|
+
* el aviso se encola en el proxio (24 h), llega tarde y lo tira la ventana de
|
|
580
|
+
* frescura (5 min), o caduca en la cola y no llega nunca. En los tres casos el
|
|
581
|
+
* agente se quedaba con la configuración vieja PARA SIEMPRE, porque al reconectar
|
|
582
|
+
* solo volvía a escuchar: nunca preguntaba. Ahora, cada vez que la conexión se
|
|
583
|
+
* restablece, pide el bundle y compara su huella con la que tiene aplicada; si no
|
|
584
|
+
* coincide, reacciona igual que si hubiera llegado el aviso. El aviso es el camino
|
|
585
|
+
* rápido; esto es el que no se pierde.
|
|
586
|
+
*
|
|
587
|
+
* Y lo mismo salva al interruptor de emergencia: si el cert se revocó mientras
|
|
588
|
+
* estaba incomunicado, el `REVOKED` se perdió igual, pero la comparación recibe
|
|
589
|
+
* «unauthorized: revoked» y apaga al agente ahí mismo.
|
|
590
|
+
*
|
|
391
591
|
* Defensas, porque una señal que provoca reinicios es un arma si se descuida:
|
|
392
592
|
* · **Firma de la maestra pineada** y `ns` que coincida. Sin esto, cualquiera
|
|
393
593
|
* reinicia la flota ajena cuando quiera.
|
|
@@ -402,30 +602,53 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
402
602
|
* @param {Object} opts
|
|
403
603
|
* @param {string} opts.dir Identidad del servicio (`service-identity.json`).
|
|
404
604
|
* @param {string} [opts.ns]
|
|
405
|
-
* @param {(info:{ns:string, ts:number})=>void} opts.onChange
|
|
605
|
+
* @param {(info:{ns:string, ts:number, via:'notice'|'reconcile'})=>void} opts.onChange
|
|
606
|
+
* `via` dice por dónde se enteró: `notice` (llegó el aviso) o `reconcile` (nadie
|
|
607
|
+
* avisó y la comparación al reconectar encontró otra configuración).
|
|
406
608
|
* @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
|
|
609
|
+
* @param {Record<string,string>} [opts.applied] El bundle que el agente tiene EN USO.
|
|
610
|
+
* Pasarlo es lo que permite detectar un cambio ya en la PRIMERA conexión — el que
|
|
611
|
+
* ocurrió entre que el agente pidió su configuración y logró ponerse a escuchar. Sin
|
|
612
|
+
* él no se pierde la protección, solo empieza una conexión más tarde: la primera se
|
|
613
|
+
* limita a tomar la referencia.
|
|
407
614
|
* @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
|
|
615
|
+
* La comparación también lo respeta, pero **aplazándose** (el aviso sí se descarta):
|
|
616
|
+
* es lo que impide que un fallo sistemático se convierta en un ciclo de reinicios,
|
|
617
|
+
* porque acota los reinicios por comparación a uno por ventana.
|
|
408
618
|
* @param {number} [opts.minIntervalMs=60000] Mínimo entre dos avisos obedecidos.
|
|
409
619
|
* @param {number} [opts.jitterMs=5000] Espera aleatoria antes de avisar.
|
|
620
|
+
* @param {number} [opts.reconcileMinMs=30000] Mínimo entre dos comparaciones. Sin él,
|
|
621
|
+
* una conexión que va y viene cada cinco segundos le pediría el bundle a la bóveda
|
|
622
|
+
* cada cinco segundos, y son N agentes.
|
|
410
623
|
* @param {(m:string)=>void} [opts.log]
|
|
411
|
-
* @returns {Promise<{stop:()=>void}>}
|
|
624
|
+
* @returns {Promise<{stop:()=>void, reconcile:()=>Promise<boolean>}>}
|
|
625
|
+
* `reconcile()` fuerza la comparación (útil desde un chequeo de salud); devuelve si
|
|
626
|
+
* encontró un cambio.
|
|
412
627
|
*/
|
|
413
628
|
export async function watchSecretsChanges ({
|
|
414
|
-
dir, ns, onChange, onRevoked, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
|
|
629
|
+
dir, ns, onChange, onRevoked, applied, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
|
|
630
|
+
reconcileMinMs = 30000, log = () => {}
|
|
415
631
|
} = {}) {
|
|
416
632
|
const saved = dir ? readServiceIdentity(dir) : null
|
|
417
633
|
ns = ns || saved?.ns
|
|
418
634
|
if (!saved?.device || !saved?.cert || !saved?.iss || !saved?.proxy) {
|
|
419
|
-
throw new Error('
|
|
635
|
+
throw new Error('service not enrolled: nobody to listen to')
|
|
420
636
|
}
|
|
421
637
|
const master = saved.iss
|
|
422
|
-
const
|
|
423
|
-
let
|
|
424
|
-
let
|
|
425
|
-
const
|
|
426
|
-
let
|
|
638
|
+
const bornAt = Date.now()
|
|
639
|
+
let lastTs = 0
|
|
640
|
+
let lastObeyed = 0
|
|
641
|
+
const inFlight = new Set() // avisos cuya firma se está comprobando ahora mismo
|
|
642
|
+
let stopped = false
|
|
427
643
|
let client = null
|
|
428
|
-
let
|
|
644
|
+
let retryTimer = null
|
|
645
|
+
// Huella de la configuración EN USO. Comparar huellas y no valores es lo que permite
|
|
646
|
+
// decir «esto no es lo que está corriendo» sin volver a manejar los secretos.
|
|
647
|
+
let fingerprint = applied === undefined ? null : fingerprintOf(applied)
|
|
648
|
+
let firstConnection = true
|
|
649
|
+
let lastReconcile = 0
|
|
650
|
+
let reconciling = false
|
|
651
|
+
let reconcileRetry = null
|
|
429
652
|
|
|
430
653
|
/**
|
|
431
654
|
* REVOCACIÓN = interruptor de emergencia. Hasta ahora revocar un cert no le
|
|
@@ -433,12 +656,12 @@ export async function watchSecretsChanges ({
|
|
|
433
656
|
* memoria hasta que alguien se acordara de reiniciarlo (el README decía lo
|
|
434
657
|
* contrario). Teniendo la conexión abierta, el aviso llega y el agente se apaga
|
|
435
658
|
* en el acto — y no vuelve, porque al arrancar `fetchSecrets` recibe
|
|
436
|
-
* «
|
|
659
|
+
* «unauthorized: revoked», que no se arregla reintentando.
|
|
437
660
|
*
|
|
438
661
|
* Sin gracia, sin piso y sin jitter, al revés que un cambio de configuración:
|
|
439
662
|
* apagar algo comprometido es justo lo que no debe esperar su turno.
|
|
440
663
|
*/
|
|
441
|
-
const
|
|
664
|
+
const handleRevocation = async (payload) => {
|
|
442
665
|
const body = payload.body
|
|
443
666
|
if (!body || body.op !== 'revoke') return
|
|
444
667
|
// Que sea MI revocación y no la de otro dispositivo del mismo dueño.
|
|
@@ -451,84 +674,223 @@ export async function watchSecretsChanges ({
|
|
|
451
674
|
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
452
675
|
}
|
|
453
676
|
|
|
454
|
-
|
|
455
|
-
|
|
677
|
+
/**
|
|
678
|
+
* REPARTIR LA LLAVE DE MI CAJÓN a un miembro nuevo (§8.11 del diseño).
|
|
679
|
+
*
|
|
680
|
+
* Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
|
|
681
|
+
* la bóveda no se la puede hacer: envolver exige abrir la llave, y abrirla pide la
|
|
682
|
+
* frase. Este agente SÍ la tiene abierta, así que la reparte él. No gana ningún poder
|
|
683
|
+
* haciéndolo —ya podía leer eso— y por eso es el único que puede hacerlo sin que
|
|
684
|
+
* nadie ceda nada.
|
|
685
|
+
*
|
|
686
|
+
* NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
|
|
687
|
+
* bóveda estuviera comprometida:
|
|
688
|
+
*
|
|
689
|
+
* · la petición va firmada por la MAESTRA;
|
|
690
|
+
* · el acta viaja dentro y se comprueba aparte (también la firma la maestra);
|
|
691
|
+
* · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
|
|
692
|
+
* se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
|
|
693
|
+
* la llave para una pública suya;
|
|
694
|
+
* · y el destinatario tiene que ser de ESTE cajón (`cn === ns`): un servicio no puede
|
|
695
|
+
* ampliar el acceso a nada que no sea lo suyo.
|
|
696
|
+
*/
|
|
697
|
+
const handleRewrap = async (payload) => {
|
|
698
|
+
const body = payload?.body
|
|
699
|
+
if (!body || body.op !== 'rewrap') return
|
|
700
|
+
const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
|
|
701
|
+
if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
|
|
702
|
+
if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
|
|
703
|
+
return log('[vault] rewrap request BADLY SIGNED: ignored')
|
|
704
|
+
}
|
|
705
|
+
const acta = body.acta
|
|
706
|
+
if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
|
|
707
|
+
return log('[vault] rewrap request without a valid record: ignored')
|
|
708
|
+
}
|
|
709
|
+
const target = (acta.members || []).find((m) => m.pub === body.target)
|
|
710
|
+
if (!target?.encPub) return log('[vault] rewrap: the target is not in the record (or has no encryption key)')
|
|
711
|
+
if (target.cn !== ns) return log(`[vault] rewrap: ${String(body.target).slice(0, 12)}… is not part of «${ns}»: refused`)
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
const ident = readServiceIdentity(dir)
|
|
715
|
+
if (!ident?.enc?.privateJwk) return log('[vault] rewrap: this agent has no encryption key')
|
|
716
|
+
const cek = await openWrap({ wrap: body.wrap, myEncPrivateKey: await importDeviceEncKey(ident.enc.privateJwk) })
|
|
717
|
+
const wrap = await wrapForMember({ cek, memberEncPub: target.encPub })
|
|
718
|
+
const data = { op: 'rewrap.ok', owner: body.owner, gen: body.gen, target: body.target, wrap, ts: Date.now() }
|
|
719
|
+
const { signature } = await signWithDevice({ privateJwk: saved.device.privateJwk, data })
|
|
720
|
+
client.sendByPubkey(master, { type: MSG.REWRAP_OK, data, signature, cert: saved.cert })
|
|
721
|
+
log(`[vault] key handed to ${String(body.target).slice(0, 12)}… for ${body.owner} (gen ${body.gen})`)
|
|
722
|
+
} catch (e) {
|
|
723
|
+
log('[vault] rewrap failed: ' + e.message)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const handleMessage = async (payload) => {
|
|
728
|
+
if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
|
|
729
|
+
if (payload?.type === MSG.REWRAP) return handleRewrap(payload)
|
|
456
730
|
if (payload?.type !== MSG.SECRETS_CHANGED) return
|
|
457
731
|
const body = payload.body
|
|
458
732
|
if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
|
|
459
733
|
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) {
|
|
460
|
-
return log('[vault]
|
|
734
|
+
return log('[vault] change notice dated outside the window: ignored')
|
|
461
735
|
}
|
|
462
|
-
if (body.ts <=
|
|
736
|
+
if (body.ts <= lastTs) return log('[vault] repeated change notice: ignored')
|
|
463
737
|
// 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 `
|
|
738
|
+
// asíncrono: sin esta marca las dos pasarían el corte de `lastTs` antes de
|
|
465
739
|
// que ninguna lo actualizara, y el agente se reiniciaría por partida doble.
|
|
466
|
-
// La marca se pone antes del `await` y el `
|
|
740
|
+
// La marca se pone antes del `await` y el `lastTs` DESPUÉS de verificar, para
|
|
467
741
|
// que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
let
|
|
742
|
+
if (inFlight.has(body.ts)) return
|
|
743
|
+
inFlight.add(body.ts)
|
|
744
|
+
let valid = false
|
|
471
745
|
try {
|
|
472
|
-
|
|
473
|
-
} finally {
|
|
474
|
-
if (!
|
|
475
|
-
if (body.ts <=
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
if (
|
|
746
|
+
valid = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
|
|
747
|
+
} finally { inFlight.delete(body.ts) }
|
|
748
|
+
if (!valid) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
|
|
749
|
+
if (body.ts <= lastTs) return
|
|
750
|
+
lastTs = body.ts
|
|
751
|
+
|
|
752
|
+
const now = Date.now()
|
|
753
|
+
if (now - bornAt < graceMs) {
|
|
480
754
|
return log('[vault] change notice right after start: ignored (avoids the restart loop)')
|
|
481
755
|
}
|
|
482
|
-
if (
|
|
483
|
-
return log('[vault]
|
|
756
|
+
if (now - lastObeyed < minIntervalMs) {
|
|
757
|
+
return log('[vault] change notice too close to the previous one: ignored')
|
|
484
758
|
}
|
|
485
|
-
|
|
759
|
+
lastObeyed = now
|
|
486
760
|
|
|
487
|
-
const
|
|
488
|
-
log(`[vault] the vault reports config for "${ns}" changed (in ${
|
|
489
|
-
setTimeout(() => { if (!
|
|
761
|
+
const wait = Math.floor(Math.random() * jitterMs)
|
|
762
|
+
log(`[vault] the vault reports config for "${ns}" changed (in ${wait} ms)`)
|
|
763
|
+
setTimeout(() => { if (!stopped) { try { onChange?.({ ns, ts: body.ts, via: 'notice' }) } catch (e) { log('[vault] ' + e.message) } } }, wait)
|
|
490
764
|
}
|
|
491
765
|
|
|
492
|
-
|
|
493
|
-
|
|
766
|
+
/**
|
|
767
|
+
* PREGUNTA en vez de esperar a que le cuenten: pide el bundle y lo compara con el
|
|
768
|
+
* que está en uso. Es la red que recoge todo lo que el aviso deja caer — el que se
|
|
769
|
+
* perdió mientras el agente estaba incomunicado, el que llegó fuera de la ventana de
|
|
770
|
+
* frescura, el que caducó en la cola del proxio y el que el propio agente descartó.
|
|
771
|
+
*
|
|
772
|
+
* Lo que compara son DOS BUNDLES DE LA BÓVEDA, nunca el `.env` contra el bundle. Por
|
|
773
|
+
* eso recibir la configuración por primera vez —tarde, que es como la recibe el
|
|
774
|
+
* proxio— no es un cambio: la referencia es lo que el agente recibió, no lo que tenía
|
|
775
|
+
* antes de recibir nada. Es la razón de fondo por la que esto no puede volverse un
|
|
776
|
+
* ciclo de reinicios; el tope de frecuencia de abajo es el cinturón.
|
|
777
|
+
*
|
|
778
|
+
* No pasa por el piso entre avisos, y es a propósito: ese freno existe porque un aviso
|
|
779
|
+
* es una señal que alguien podría repetir para provocar reinicios. Esto no es una
|
|
780
|
+
* señal, es el estado real firmado por la maestra — si de verdad difiere, reiniciar es
|
|
781
|
+
* siempre lo correcto, y al volver ya coincide.
|
|
782
|
+
*
|
|
783
|
+
* @returns {Promise<boolean>} si encontró (y anunció) un cambio.
|
|
784
|
+
*/
|
|
785
|
+
const reconcile = async (trigger) => {
|
|
786
|
+
if (stopped || reconciling) return false
|
|
787
|
+
if (Date.now() - lastReconcile < reconcileMinMs) return false
|
|
788
|
+
// TOPE DE FRECUENCIA, que es lo único que separa esto de un ciclo de reinicios.
|
|
789
|
+
// Reiniciar por comparación no puede repetirse más de una vez por gracia de
|
|
790
|
+
// arranque: si algo hiciera que la comparación fallara SIEMPRE, el proceso saldría
|
|
791
|
+
// cada 30 s y no cada dos, que es la diferencia entre que el supervisor lo note y
|
|
792
|
+
// que la máquina se pase el día arrancando.
|
|
793
|
+
//
|
|
794
|
+
// Y a diferencia del aviso, aquí no se DESCARTA: se APLAZA. Descartarlo era el
|
|
795
|
+
// defecto que este cambio vino a cerrar, así que reintroducirlo por la puerta de
|
|
796
|
+
// atrás sería el peor final posible.
|
|
797
|
+
const sinceStart = Date.now() - bornAt
|
|
798
|
+
if (sinceStart < graceMs) {
|
|
799
|
+
clearTimeout(reconcileRetry)
|
|
800
|
+
reconcileRetry = setTimeout(() => { reconcile(trigger).catch(() => {}) }, graceMs - sinceStart + 50)
|
|
801
|
+
reconcileRetry.unref?.()
|
|
802
|
+
return false
|
|
803
|
+
}
|
|
804
|
+
reconciling = true
|
|
805
|
+
let bundle = null
|
|
806
|
+
try {
|
|
807
|
+
bundle = await fetchSecrets({ dir, ns })
|
|
808
|
+
} catch (e) {
|
|
809
|
+
// El cert revocado mientras estaba incomunicado: el `REVOKED` firmado se perdió
|
|
810
|
+
// igual que el aviso, y esta es la única otra forma de enterarse. Lo demás (la
|
|
811
|
+
// bóveda apagada, el proxio a medio levantar) es transitorio y se reintenta en la
|
|
812
|
+
// siguiente conexión: no se apaga nada por no haber podido preguntar.
|
|
813
|
+
if (/unauthorized: revoked/.test(e.message)) {
|
|
814
|
+
log('[vault] ⚠ the vault REVOKED this agent cert (noticed on ' + trigger + '): shutting down')
|
|
815
|
+
try { onRevoked?.({ nonce: saved.cert?.nonce || null }) } catch (err) { log('[vault] ' + err.message) }
|
|
816
|
+
} else {
|
|
817
|
+
log('[vault] could not check the config on ' + trigger + ': ' + e.message)
|
|
818
|
+
}
|
|
819
|
+
return false
|
|
820
|
+
} finally {
|
|
821
|
+
reconciling = false
|
|
822
|
+
lastReconcile = Date.now()
|
|
823
|
+
}
|
|
824
|
+
const current = fingerprintOf(bundle)
|
|
825
|
+
if (fingerprint === null) { fingerprint = current; return false } // primera vez: solo tomar referencia
|
|
826
|
+
if (current === fingerprint) return false
|
|
827
|
+
fingerprint = current
|
|
828
|
+
lastObeyed = Date.now()
|
|
829
|
+
log(`[vault] the config for "${ns}" is not the one running (noticed on ${trigger}): the notice never arrived`)
|
|
830
|
+
if (!stopped) { try { onChange?.({ ns, ts: Date.now(), via: 'reconcile' }) } catch (e) { log('[vault] ' + e.message) } }
|
|
831
|
+
return true
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const connect = async () => {
|
|
835
|
+
if (stopped) return
|
|
494
836
|
try {
|
|
495
837
|
client = await freshClient(saved.proxy)
|
|
496
838
|
await identifyAsService(client, saved.device)
|
|
497
|
-
client.on('message', (_from, p) => {
|
|
839
|
+
client.on('message', (_from, p) => { handleMessage(p).catch(() => {}) })
|
|
498
840
|
// Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
|
|
499
841
|
// eso es exactamente el momento en que uno querría enterarse de una rotación.
|
|
500
|
-
client.on('disconnected', () => { if (!
|
|
842
|
+
client.on('disconnected', () => { if (!stopped) retryTimer = setTimeout(connect, 5000) })
|
|
501
843
|
log('[vault] listening for config changes')
|
|
844
|
+
// Y COMPARAR, porque el rato sin conexión es justo cuando se pierde un aviso.
|
|
845
|
+
// También en la PRIMERA conexión, aunque el agente venga de pedir el bundle hace
|
|
846
|
+
// un instante: entre aquello y esto pudo pasar cualquier cosa —si el proxio estaba
|
|
847
|
+
// caído, esta primera conexión llega minutos después— y ahí ya no hay quien avise.
|
|
848
|
+
// Cuesta una consulta por arranque; el hueco que tapa no tiene límite.
|
|
849
|
+
const trigger = firstConnection ? 'startup' : 'reconnect'
|
|
850
|
+
firstConnection = false
|
|
851
|
+
reconcile(trigger).catch(() => {})
|
|
502
852
|
} catch (e) {
|
|
503
|
-
if (!
|
|
853
|
+
if (!stopped) retryTimer = setTimeout(connect, 5000)
|
|
504
854
|
}
|
|
505
855
|
}
|
|
506
|
-
await
|
|
856
|
+
await connect()
|
|
507
857
|
|
|
508
858
|
return {
|
|
509
859
|
stop () {
|
|
510
|
-
|
|
511
|
-
clearTimeout(
|
|
860
|
+
stopped = true
|
|
861
|
+
clearTimeout(retryTimer)
|
|
862
|
+
clearTimeout(reconcileRetry)
|
|
512
863
|
try { client?.close() } catch (_) {}
|
|
513
|
-
}
|
|
864
|
+
},
|
|
865
|
+
reconcile: () => reconcile('demand')
|
|
514
866
|
}
|
|
515
867
|
}
|
|
516
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Huella de un bundle. Las claves van ORDENADAS: el bundle se arma mezclando el cajón
|
|
871
|
+
* del scope con el del aparato, así que el mismo contenido puede llegar en otro orden y
|
|
872
|
+
* un cambio de orden no es un cambio de configuración.
|
|
873
|
+
*/
|
|
874
|
+
function fingerprintOf (secrets) {
|
|
875
|
+
const pairs = Object.entries(secrets || {}).map(([k, v]) => [k, String(v)]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
876
|
+
return createHash('sha256').update(JSON.stringify(pairs)).digest('hex')
|
|
877
|
+
}
|
|
878
|
+
|
|
517
879
|
/**
|
|
518
880
|
* Bucle de arranque de un servicio: pide los secretos y, si el vault no está
|
|
519
881
|
* disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
|
|
520
882
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
521
883
|
* @returns {Promise<Record<string,string>>}
|
|
522
884
|
*/
|
|
523
|
-
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 } = {}) {
|
|
524
886
|
let delay = retryMs
|
|
525
887
|
for (;;) {
|
|
526
888
|
try {
|
|
527
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
|
|
889
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
|
|
528
890
|
} catch (e) {
|
|
529
891
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
530
892
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
|
531
|
-
if (/
|
|
893
|
+
if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
|
|
532
894
|
try { onRetry?.(e, delay) } catch (_) {}
|
|
533
895
|
await new Promise((r) => setTimeout(r, delay))
|
|
534
896
|
delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
|