@dotrino/vault 0.22.0 → 0.24.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 +1 -1
- package/bin/dotrino-env.js +5 -5
- package/package.json +9 -2
- package/src/admin.js +88 -3
- package/src/config.js +1 -1
- package/src/enroll.js +21 -21
- package/src/env.js +37 -17
- package/src/envtext.js +94 -0
- package/src/index.js +4 -4
- package/src/invite.js +8 -8
- package/src/protocol.js +12 -0
- package/src/service.js +218 -92
- package/src/types.d.ts +5 -0
package/README.md
CHANGED
|
@@ -173,7 +173,7 @@ Salir en vez de recargar, por tres razones — y la primera es la de peso:
|
|
|
173
173
|
queda corta, falla en silencio.
|
|
174
174
|
3. **Es un interruptor de emergencia.** Revocar el cert de un agente ya no espera a
|
|
175
175
|
que alguien se acuerde de reiniciarlo: recibe el `REVOKED` firmado, se apaga, y
|
|
176
|
-
al arrancar `fetchSecrets` recibe «
|
|
176
|
+
al arrancar `fetchSecrets` recibe «unauthorized: revoked», que no se arregla
|
|
177
177
|
reintentando. Antes, revocar no le quitaba nada a un proceso ya corriendo.
|
|
178
178
|
|
|
179
179
|
Defensas, porque una señal que provoca reinicios es un arma si se descuida: firma
|
package/bin/dotrino-env.js
CHANGED
|
@@ -58,7 +58,7 @@ Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET
|
|
|
58
58
|
*/
|
|
59
59
|
function parseInvite (raw) {
|
|
60
60
|
const o = sharedParseInvite(raw)
|
|
61
|
-
if (!o) throw new Error('
|
|
61
|
+
if (!o) throw new Error('that does not look like a vault invitation')
|
|
62
62
|
return o
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -81,7 +81,7 @@ async function cmdEnroll () {
|
|
|
81
81
|
qr,
|
|
82
82
|
ns,
|
|
83
83
|
dir,
|
|
84
|
-
label: flag('label') || '
|
|
84
|
+
label: flag('label') || 'service:' + ns,
|
|
85
85
|
// Un agente tiene UNA identidad y se la da el vault: re-enrolar REEMPLAZA,
|
|
86
86
|
// no acumula. Antes había una reja (`--force`) que hacía de esto un error a
|
|
87
87
|
// desbloquear; sobra, porque no existe la alternativa de "quedarse con las
|
|
@@ -130,9 +130,9 @@ async function cmdCheck () {
|
|
|
130
130
|
console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
|
|
131
131
|
for (const k of keys) console.log(' ' + k) // NUNCA los valores
|
|
132
132
|
// Delata el `.env` rancio: qué claves de esta máquina el vault pisaría.
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
console.log('\nEl vault PISA estos valores del entorno de esta máquina:\n %s',
|
|
133
|
+
const clashing = keys.filter((k) => k in process.env && process.env[k] !== String(secrets[k]))
|
|
134
|
+
if (clashing.length) {
|
|
135
|
+
console.log('\nEl vault PISA estos valores del entorno de esta máquina:\n %s', clashing.join(', '))
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Usa ESTE dispositivo (navegador) como
|
|
3
|
+
"version": "0.24.0",
|
|
4
|
+
"description": "Usa ESTE dispositivo (navegador) como b\u00f3veda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegaci\u00f3n a tus m\u00e1quinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
|
@@ -50,5 +50,12 @@
|
|
|
50
50
|
"type": "git",
|
|
51
51
|
"url": "git+https://github.com/imdotrino/dotrino-vault.git",
|
|
52
52
|
"directory": "lib"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "^5.7.3",
|
|
56
|
+
"@types/node": "^22.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"type-check": "tsc --noEmit"
|
|
53
60
|
}
|
|
54
61
|
}
|
package/src/admin.js
CHANGED
|
@@ -9,8 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* sí · ver el acta y la bitácora · iniciar un emparejamiento (mostrar el QR)
|
|
11
11
|
* · APROBAR o rechazar a quien entra · REVOCAR a un miembro
|
|
12
|
+
* · VARIABLES DE ENTORNO: crearlas y darles valor (de un scope o de un aparato),
|
|
13
|
+
* y ver el valor de las marcadas PÚBLICAS
|
|
12
14
|
* no · cambiar permisos · traspasar el mando · conceder `admin`
|
|
13
|
-
* ·
|
|
15
|
+
* · ver el valor de una variable PRIVADA · borrar variables
|
|
16
|
+
*
|
|
17
|
+
* SOBRE LAS VARIABLES, que es la rendija más nueva: lo que cruza la frontera no es «los
|
|
18
|
+
* secretos» sino los que su dueño MARCÓ como mostrables. Una privada se puede reescribir
|
|
19
|
+
* a ciegas desde la consola, pero su valor no sale de la máquina de la bóveda ni para un
|
|
20
|
+
* aparato tuyo con `admin`. Y los valores que sí salen viajan CIFRADOS con la clave de
|
|
21
|
+
* contenido del perfil (quien llama sella y abre): el proxy transporta y no ve nada.
|
|
14
22
|
*
|
|
15
23
|
* La frontera no es un capricho: un admin puede **admitir y expulsar**, pero no
|
|
16
24
|
* reescribir quién manda. Así un aparato con `admin` robado hace daño **acotado y
|
|
@@ -26,7 +34,15 @@
|
|
|
26
34
|
*/
|
|
27
35
|
|
|
28
36
|
/** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
|
|
29
|
-
export const ADMIN_OPS = Object.freeze([
|
|
37
|
+
export const ADMIN_OPS = Object.freeze([
|
|
38
|
+
'pending', 'pair', 'approve', 'reject', 'revoke', 'audit',
|
|
39
|
+
// Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
|
|
40
|
+
// ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
|
|
41
|
+
// no debe poder dejar sin configuración a los servicios.
|
|
42
|
+
// `var.setMany` es la MISMA operación con varias variables dentro de un solo sobre: no
|
|
43
|
+
// añade permisos, quita reinicios (ver el enrutado abajo).
|
|
44
|
+
'vars', 'var.set', 'var.setMany'
|
|
45
|
+
])
|
|
30
46
|
|
|
31
47
|
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
32
48
|
export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
|
|
@@ -40,13 +56,18 @@ export const AUDIT_MAX = 500
|
|
|
40
56
|
* @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
|
|
41
57
|
* @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
|
|
42
58
|
* @param {(pub:string)=>Promise<string>} o.deviceIdOf
|
|
59
|
+
* @param {{list:(a:object)=>Promise<any>, set:(a:object)=>Promise<any>, setMany?:(a:object)=>Promise<any>}} [o.vars]
|
|
60
|
+
* mostrador de VARIABLES DE ENTORNO. Va inyectado porque aquí no hay cripto ni disco:
|
|
61
|
+
* quien lo implementa (la bóveda) es quien sella con la clave de contenido del perfil y
|
|
62
|
+
* quien decide qué valor puede salir. Sin él, las ops de variables responden que esta
|
|
63
|
+
* bóveda no las atiende, en vez de fingir que se aplicaron.
|
|
43
64
|
* @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
|
|
44
65
|
* @param {(op:string, info?:object)=>void} [o.audit]
|
|
45
66
|
* @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
|
|
46
67
|
* @param {number} [o.ttlMs] vida del cert que se emita.
|
|
47
68
|
*/
|
|
48
69
|
export function createAdminDesk ({
|
|
49
|
-
desk, verify, readActivity = () => [], deviceIdOf,
|
|
70
|
+
desk, verify, readActivity = () => [], deviceIdOf, vars = null,
|
|
50
71
|
notify = async () => {}, audit = () => {},
|
|
51
72
|
defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
|
|
52
73
|
ttlMs, now = () => Date.now()
|
|
@@ -127,6 +148,70 @@ export function createAdminDesk ({
|
|
|
127
148
|
return { ok: true, result: { ok: true } }
|
|
128
149
|
}
|
|
129
150
|
|
|
151
|
+
// VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
|
|
152
|
+
// qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
|
|
153
|
+
if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany') {
|
|
154
|
+
if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
|
|
155
|
+
// Un destino y solo uno: o un scope, o un aparato. Sin esto, mandar los dos dejaría
|
|
156
|
+
// que quien llama adivine dónde acabó su variable.
|
|
157
|
+
// Booleanos a propósito: comparar los VALORES («proxy» vs una pubkey) nunca da
|
|
158
|
+
// igual, así que mandar los dos destinos se colaba por el hueco.
|
|
159
|
+
const toScope = typeof data.ns === 'string' && !!data.ns
|
|
160
|
+
const toDevice = typeof data.pub === 'string' && !!data.pub
|
|
161
|
+
if (data.op === 'vars') {
|
|
162
|
+
const result = await vars.list({ by })
|
|
163
|
+
audit('admin.vars', { by })
|
|
164
|
+
return { ok: true, result }
|
|
165
|
+
}
|
|
166
|
+
if (toScope === toDevice) return { ok: false, error: 'admin: var.set needs exactly one target (ns or pub)' }
|
|
167
|
+
|
|
168
|
+
// VARIAS DE UNA VEZ. Mismo permiso y misma frontera que una sola: lo que cambia es
|
|
169
|
+
// que la bóveda las guarda juntas y manda UN aviso de cambio en vez de uno por
|
|
170
|
+
// variable — o sea, el servicio se reinicia una vez, con la configuración entera,
|
|
171
|
+
// en lugar de arrancar a medias mientras quien administra sigue escribiendo.
|
|
172
|
+
// Los nombres viajan DENTRO del sobre, igual que los valores: el proxy tampoco
|
|
173
|
+
// tiene por qué aprender cómo se llaman las variables de un servicio.
|
|
174
|
+
if (data.op === 'var.setMany') {
|
|
175
|
+
// Una bóveda anterior a esto sabe guardar de una en una y nada más. Decirlo es
|
|
176
|
+
// mejor que reventar con un TypeError que no explica qué falta actualizar.
|
|
177
|
+
if (typeof vars.setMany !== 'function') return { ok: false, error: 'admin: this vault cannot save several variables at once (update it)' }
|
|
178
|
+
if (!data.enc || typeof data.enc !== 'object') {
|
|
179
|
+
return { ok: false, error: 'admin: var.setMany needs the variables sealed with the profile content key' }
|
|
180
|
+
}
|
|
181
|
+
const result = await vars.setMany({
|
|
182
|
+
ns: toScope ? data.ns : null,
|
|
183
|
+
pub: toDevice ? data.pub : null,
|
|
184
|
+
enc: data.enc,
|
|
185
|
+
public: typeof data.public === 'boolean' ? data.public : undefined,
|
|
186
|
+
by
|
|
187
|
+
})
|
|
188
|
+
const keys = result?.keys || []
|
|
189
|
+
audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
|
|
190
|
+
await notify('vars', { by, keys, ns: toScope ? data.ns : null })
|
|
191
|
+
return { ok: true, result: result || { ok: true } }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.set needs a key' }
|
|
195
|
+
if (!data.enc || typeof data.enc !== 'object') {
|
|
196
|
+
// El valor NUNCA viaja en claro: si llega sin sobre, es un error de quien llama,
|
|
197
|
+
// no algo que se pueda «arreglar» aceptándolo.
|
|
198
|
+
return { ok: false, error: 'admin: var.set needs the value sealed with the profile content key' }
|
|
199
|
+
}
|
|
200
|
+
const result = await vars.set({
|
|
201
|
+
ns: toScope ? data.ns : null,
|
|
202
|
+
pub: toDevice ? data.pub : null,
|
|
203
|
+
key: data.key,
|
|
204
|
+
enc: data.enc,
|
|
205
|
+
public: typeof data.public === 'boolean' ? data.public : undefined,
|
|
206
|
+
by
|
|
207
|
+
})
|
|
208
|
+
audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
|
|
209
|
+
// Cambiar la configuración de un servicio a distancia no puede ser invisible: es
|
|
210
|
+
// la contrapartida de delegar (F3 de docs/consola-remota.md).
|
|
211
|
+
await notify('vars', { by, key: data.key, ns: toScope ? data.ns : null })
|
|
212
|
+
return { ok: true, result: result || { ok: true } }
|
|
213
|
+
}
|
|
214
|
+
|
|
130
215
|
// QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
|
|
131
216
|
// todos los certificados. Las dos cosas o ninguna.
|
|
132
217
|
//
|
package/src/config.js
CHANGED
|
@@ -53,6 +53,6 @@ if (process.env.DOTRINO_ENV_WATCH !== '0') {
|
|
|
53
53
|
try {
|
|
54
54
|
await watchEnv({ ns, quiet })
|
|
55
55
|
} catch (e) {
|
|
56
|
-
if (!quiet) console.error('[dotrino-env]
|
|
56
|
+
if (!quiet) console.error('[dotrino-env] no watch for changes (%s): a rotation will need a manual restart', e.message)
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/enroll.js
CHANGED
|
@@ -234,7 +234,7 @@ export function createEnrollDesk ({
|
|
|
234
234
|
}
|
|
235
235
|
if (intent !== (pend.mode || 'join')) {
|
|
236
236
|
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
237
|
-
return reply(from, { type: MSG_ERROR, error: `
|
|
237
|
+
return reply(from, { type: MSG_ERROR, error: `this pairing was opened for "${pend.mode || 'join'}" and the device asked for "${intent}"` })
|
|
238
238
|
}
|
|
239
239
|
if (!isFresh(d)) {
|
|
240
240
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
@@ -332,25 +332,25 @@ export function createEnrollDesk ({
|
|
|
332
332
|
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
333
333
|
// credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
|
|
334
334
|
// Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
|
|
335
|
-
let
|
|
335
|
+
let record = null
|
|
336
336
|
try {
|
|
337
337
|
if (typeof identity.admitMember === 'function') {
|
|
338
338
|
const cn = scopeToCn(pend.scope)
|
|
339
339
|
const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
|
|
340
340
|
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
341
341
|
}
|
|
342
|
-
|
|
343
|
-
} catch (e) { log('[vault]
|
|
342
|
+
record = (await identity.profileActa?.())?.acta || null
|
|
343
|
+
} catch (e) { log('[vault] could not admit into the record:', e.message) }
|
|
344
344
|
|
|
345
345
|
audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
|
|
346
346
|
// Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
|
|
347
347
|
// con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
|
|
348
348
|
// El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
|
|
349
|
-
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
|
|
349
|
+
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta: record })
|
|
350
350
|
pend.state = 'DONE'
|
|
351
351
|
pending.delete(pend.token)
|
|
352
352
|
fire(onPendingChange)
|
|
353
|
-
log('[vault]
|
|
353
|
+
log('[vault] device approved: %s', pend.deviceId)
|
|
354
354
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
355
355
|
}
|
|
356
356
|
|
|
@@ -370,39 +370,39 @@ export function createEnrollDesk ({
|
|
|
370
370
|
* pisar una cuenta con datos, y eso no puede pasar por accidente.
|
|
371
371
|
*/
|
|
372
372
|
async function handleActaSealed (from, p) {
|
|
373
|
-
const
|
|
373
|
+
const record = p?.acta
|
|
374
374
|
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
375
375
|
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
|
|
376
|
-
if (!
|
|
377
|
-
if (
|
|
376
|
+
if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
|
|
377
|
+
if (record.sealer !== iss) {
|
|
378
378
|
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
379
379
|
return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
|
|
380
380
|
}
|
|
381
|
-
if (
|
|
381
|
+
if (record.sealedBy !== pend.dpub) {
|
|
382
382
|
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
383
383
|
return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
|
|
384
384
|
}
|
|
385
|
-
if (pend.profileId &&
|
|
385
|
+
if (pend.profileId && record.profileId !== pend.profileId) {
|
|
386
386
|
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
387
387
|
return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
try {
|
|
391
|
-
const r = await identity.joinProfile(
|
|
391
|
+
const r = await identity.joinProfile(record)
|
|
392
392
|
if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
|
|
393
|
-
audit('adopt', { device: pend.deviceId, profile:
|
|
393
|
+
audit('adopt', { device: pend.deviceId, profile: record.profileId, seq: record.seq })
|
|
394
394
|
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
395
395
|
// dos quedan en la misma versión.
|
|
396
|
-
const
|
|
397
|
-
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta:
|
|
396
|
+
const mine = (await identity.profileActa?.())?.acta || record
|
|
397
|
+
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mine })
|
|
398
398
|
pend.state = 'DONE'
|
|
399
399
|
pending.delete(pend.token)
|
|
400
400
|
fire(onPendingChange)
|
|
401
|
-
fire(onAdopted, { deviceId: pend.deviceId, profileId:
|
|
402
|
-
log('[vault]
|
|
403
|
-
return { ok: true, adopted: true, profileId:
|
|
401
|
+
fire(onAdopted, { deviceId: pend.deviceId, profileId: record.profileId, seq: mine.seq })
|
|
402
|
+
log('[vault] account adopted from device %s (profile %s)', pend.deviceId, record.profileId?.slice(0, 12))
|
|
403
|
+
return { ok: true, adopted: true, profileId: record.profileId, seq: mine.seq }
|
|
404
404
|
} catch (e) {
|
|
405
|
-
log('[vault]
|
|
405
|
+
log('[vault] could not adopt the account: %s', e.message)
|
|
406
406
|
reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
|
|
407
407
|
return { ok: false, error: e.message }
|
|
408
408
|
}
|
|
@@ -438,9 +438,9 @@ export function createEnrollDesk ({
|
|
|
438
438
|
async function revoke (nonce) {
|
|
439
439
|
audit('revoke', { nonce })
|
|
440
440
|
const { issued } = await identity.listDelegations()
|
|
441
|
-
const
|
|
441
|
+
const delegation = (issued || []).find((d) => d.nonce === nonce)
|
|
442
442
|
const res = await identity.revokeDelegation(nonce)
|
|
443
|
-
if (
|
|
443
|
+
if (delegation?.sub) await emitRevoke(delegation.sub, nonce)
|
|
444
444
|
return res
|
|
445
445
|
}
|
|
446
446
|
|
package/src/env.js
CHANGED
|
@@ -32,7 +32,7 @@ export function serviceRoot () {
|
|
|
32
32
|
/** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
|
|
33
33
|
export function serviceDir (ns) {
|
|
34
34
|
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
35
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
35
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
|
|
36
36
|
return path.join(serviceRoot(), ns)
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -50,15 +50,15 @@ export function listEnrolled () {
|
|
|
50
50
|
export function resolveNs (ns) {
|
|
51
51
|
ns = ns || process.env.DOTRINO_NS
|
|
52
52
|
if (ns) {
|
|
53
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
53
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns: ' + ns)
|
|
54
54
|
return ns
|
|
55
55
|
}
|
|
56
56
|
const found = listEnrolled()
|
|
57
57
|
if (found.length === 1) return found[0]
|
|
58
58
|
if (found.length === 0) {
|
|
59
|
-
throw new Error('no
|
|
59
|
+
throw new Error('no service enrolled on this machine: run `npx dotrino-env enroll --ns <your-app>`')
|
|
60
60
|
}
|
|
61
|
-
throw new Error(`
|
|
61
|
+
throw new Error(`several services are enrolled (${found.join(', ')}): pick one with DOTRINO_NS=<ns> or loadEnv({ ns })`)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
@@ -81,6 +81,9 @@ function overrideByDefault () {
|
|
|
81
81
|
return process.env.DOTRINO_ENV_OVERRIDE !== '0'
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** El último bundle que `applyEnv` puso a correr en este proceso (ver `watchEnv`). */
|
|
85
|
+
let lastApplied = null
|
|
86
|
+
|
|
84
87
|
/**
|
|
85
88
|
* Trae los secretos del ns desde el vault y los pone en `process.env`.
|
|
86
89
|
*
|
|
@@ -104,7 +107,7 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
|
|
|
104
107
|
|
|
105
108
|
const missing = required.filter((k) => !(k in secrets))
|
|
106
109
|
if (missing.length) {
|
|
107
|
-
throw new Error(`
|
|
110
|
+
throw new Error(`missing secrets in ns "${ns}": ${missing.join(', ')} (add them with \`dotrino-vault secret set ${ns} <KEY> <value>\`)`)
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
return { ns, secrets, ...applyEnv(secrets, override) }
|
|
@@ -130,17 +133,21 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
|
|
|
130
133
|
* @returns {{injected:string[], overridden:string[], skipped:string[]}}
|
|
131
134
|
*/
|
|
132
135
|
export function applyEnv (secrets, override = overrideByDefault()) {
|
|
136
|
+
// Lo último que este proceso puso a correr. `watchEnv` lo toma como referencia para
|
|
137
|
+
// comparar, así que cualquier agente que use `loadEnv`/`applyEnv` —o sea, todos—
|
|
138
|
+
// queda protegido del aviso perdido sin cablear nada.
|
|
139
|
+
lastApplied = { ...(secrets || {}) }
|
|
133
140
|
const injected = []
|
|
134
141
|
const overridden = []
|
|
135
142
|
const skipped = []
|
|
136
143
|
for (const [k, v] of Object.entries(secrets || {})) {
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
if (!override &&
|
|
140
|
-
const
|
|
141
|
-
process.env[k] =
|
|
144
|
+
const previous = process.env[k]
|
|
145
|
+
const had = k in process.env
|
|
146
|
+
if (!override && had) { skipped.push(k); continue }
|
|
147
|
+
const value = String(v)
|
|
148
|
+
process.env[k] = value
|
|
142
149
|
injected.push(k)
|
|
143
|
-
if (
|
|
150
|
+
if (had && previous !== value) overridden.push(k)
|
|
144
151
|
}
|
|
145
152
|
return { injected, overridden, skipped }
|
|
146
153
|
}
|
|
@@ -169,10 +176,22 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
169
176
|
* el supervisor ya trae backoff y tope de intentos, que es justo lo que evita que
|
|
170
177
|
* una configuración rota se convierta en un ciclo.
|
|
171
178
|
*
|
|
179
|
+
* No depende solo del aviso: al conectar COMPARA su configuración con la de la bóveda
|
|
180
|
+
* (`watchSecretsChanges`), porque un agente incomunicado se pierde el aviso y antes se
|
|
181
|
+
* quedaba con lo viejo para siempre.
|
|
182
|
+
*
|
|
183
|
+
* OJO con lo que se compara: el bundle de la bóveda contra el bundle de la bóveda,
|
|
184
|
+
* nunca contra el `.env`. Recibir la configuración por primera vez —tarde, que es como
|
|
185
|
+
* la recibe el proxio— **no** es un cambio, y por eso esto no puede convertirse en un
|
|
186
|
+
* ciclo de reinicios.
|
|
187
|
+
*
|
|
172
188
|
* @param {Object} [opts]
|
|
173
189
|
* @param {string} [opts.ns]
|
|
174
190
|
* @param {string} [opts.dir]
|
|
175
|
-
* @param {
|
|
191
|
+
* @param {Record<string,string>} [opts.applied] Lo que este proceso tiene EN USO. Por
|
|
192
|
+
* defecto, lo último que pasó por `applyEnv` en este proceso — o sea, lo que puso a
|
|
193
|
+
* correr `loadEnv`.
|
|
194
|
+
* @param {(info:{ns:string, ts:number, reason:'changed'|'revoked', via?:'notice'|'reconcile'})=>void} [opts.onUpdate]
|
|
176
195
|
* Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
|
|
177
196
|
* opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
|
|
178
197
|
* @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
|
|
@@ -180,27 +199,28 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
180
199
|
* también. Se elige 0 porque salir a propósito no es un fallo.
|
|
181
200
|
* @returns {Promise<{stop:()=>void}>}
|
|
182
201
|
*/
|
|
183
|
-
export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...
|
|
202
|
+
export async function watchEnv ({ ns, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
|
|
184
203
|
ns = resolveNs(ns)
|
|
185
204
|
dir = dir || serviceDir(ns)
|
|
186
205
|
const say = (m) => { if (!quiet) console.error(m) }
|
|
187
206
|
|
|
188
207
|
const exitNow = (reason) => {
|
|
189
208
|
say(`[dotrino-env] ${reason === 'revoked'
|
|
190
|
-
? '
|
|
191
|
-
: '
|
|
209
|
+
? 'the vault REVOKED this agent: exiting (it will not start again)'
|
|
210
|
+
: 'new config in the vault: exiting so the supervisor brings it back clean'}`)
|
|
192
211
|
process.exit(reason === 'revoked' ? 1 : exitCode)
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
return watchSecretsChanges({
|
|
196
215
|
dir,
|
|
197
216
|
ns,
|
|
217
|
+
applied,
|
|
198
218
|
log: say,
|
|
199
|
-
onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed' }) : exitNow('changed')),
|
|
219
|
+
onChange: ({ ts, via }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed', via }) : exitNow('changed')),
|
|
200
220
|
// Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
|
|
201
221
|
// levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
|
|
202
222
|
// reinicios fallidos es lo que hace que se note en vez de girar en silencio.
|
|
203
223
|
onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), reason: 'revoked' }) : exitNow('revoked')),
|
|
204
|
-
...
|
|
224
|
+
...rest
|
|
205
225
|
})
|
|
206
226
|
}
|
package/src/envtext.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lector de `.env` — el formato en el que la gente YA tiene la configuración de un
|
|
3
|
+
* servicio, y por lo tanto la forma natural de cargarla entera de una vez.
|
|
4
|
+
*
|
|
5
|
+
* Existe aquí, en la lib pura, porque lo usan los tres sitios desde los que se cargan
|
|
6
|
+
* variables: el CLI (`secret import`), la TUI y la consola remota (pegar el bloque en
|
|
7
|
+
* la web). Tres lectores distintos serían tres formatos distintos.
|
|
8
|
+
*
|
|
9
|
+
* POR QUÉ ESTO IMPORTA MÁS DE LO QUE PARECE: cada variable guardada suelta es, para la
|
|
10
|
+
* bóveda, un cambio de configuración, y el servicio obedece el primero —sale y lo
|
|
11
|
+
* levanta su supervisor— mientras el dueño sigue tecleando las demás. Cargarlas juntas
|
|
12
|
+
* es lo que hace que el servicio se reinicie UNA vez, con todo puesto.
|
|
13
|
+
*
|
|
14
|
+
* Los errores salen como CÓDIGOS, no como frases: quien llama los traduce (el CLI en
|
|
15
|
+
* español, la consola en los dos idiomas).
|
|
16
|
+
*/
|
|
17
|
+
import { isValidVarKey } from './protocol.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `CLAVE=valor` — la clave no lleva espacios ni `=`; el valor puede llevar de todo. Se
|
|
21
|
+
* toleran los espacios alrededor del `=` porque un `.env` escrito a mano los trae, y
|
|
22
|
+
* rechazar un archivo entero por eso sería quisquilloso sin ganar nada.
|
|
23
|
+
*/
|
|
24
|
+
export const PAIR_RE = /^([^=\s]+)\s*=\s*([\s\S]*)$/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {{items: Array<{op:'set', key:string, value:string}>,
|
|
29
|
+
* errors: Array<{code:'shape'|'dup'|'key'|'novalue'|'empty', line?:number, key?:string, first?:number}>}}
|
|
30
|
+
*/
|
|
31
|
+
export function parseEnvText (text) {
|
|
32
|
+
/** @type {Array<{op:'set', key:string, value:string}>} */
|
|
33
|
+
const items = []
|
|
34
|
+
/** @type {Array<{code:'shape'|'dup'|'key'|'novalue'|'empty', line?:number, key?:string, first?:number}>} */
|
|
35
|
+
const errors = []
|
|
36
|
+
const seen = new Map()
|
|
37
|
+
const lines = String(text || '').split(/\r?\n/)
|
|
38
|
+
|
|
39
|
+
lines.forEach((raw, idx) => {
|
|
40
|
+
const line = idx + 1
|
|
41
|
+
const trimmed = raw.trim()
|
|
42
|
+
// Línea vacía o comentario entero. Un `#` a MITAD de línea NO se corta: una
|
|
43
|
+
// contraseña puede llevarlo, y recortar el valor ahí lo estropea en silencio —que
|
|
44
|
+
// en un secreto significa un servicio que no levanta y nadie sabe por qué.
|
|
45
|
+
if (!trimmed || trimmed.startsWith('#')) return
|
|
46
|
+
const m = PAIR_RE.exec(trimmed.replace(/^export\s+/, ''))
|
|
47
|
+
if (!m) return errors.push({ code: 'shape', line })
|
|
48
|
+
|
|
49
|
+
const key = m[1]
|
|
50
|
+
const value = unquote(m[2].trim())
|
|
51
|
+
if (!isValidVarKey(key)) return errors.push({ code: 'key', line, key })
|
|
52
|
+
if (!value) return errors.push({ code: 'novalue', line, key })
|
|
53
|
+
// Repetida = casi siempre un pegado a medias. Adivinar cuál de las dos quería el
|
|
54
|
+
// dueño no es asunto de un lector de configuración.
|
|
55
|
+
if (seen.has(key)) return errors.push({ code: 'dup', line, key, first: seen.get(key) })
|
|
56
|
+
seen.set(key, line)
|
|
57
|
+
items.push({ op: 'set', key, value })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (!items.length && !errors.length) errors.push({ code: 'empty' })
|
|
61
|
+
return { items, errors }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Lo mismo, pero aceptando que todo venga en UNA línea (`K=v K2=v2`), que es lo que se
|
|
66
|
+
* puede escribir en un campo de una sola línea como el de la TUI. Un valor con espacios
|
|
67
|
+
* va entre comillas, igual que en la shell.
|
|
68
|
+
*/
|
|
69
|
+
export function parseEnvInput (text) {
|
|
70
|
+
const s = String(text || '')
|
|
71
|
+
return parseEnvText(/\r?\n/.test(s) ? s : tokenize(s).join('\n'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parte por espacios, pero no dentro de comillas. */
|
|
75
|
+
function tokenize (line) {
|
|
76
|
+
const out = []
|
|
77
|
+
let cur = ''
|
|
78
|
+
let quote = null
|
|
79
|
+
for (const ch of line) {
|
|
80
|
+
if (quote) { cur += ch; if (ch === quote) quote = null; continue }
|
|
81
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
|
|
82
|
+
if (/\s/.test(ch)) { if (cur) { out.push(cur); cur = '' } ; continue }
|
|
83
|
+
cur += ch
|
|
84
|
+
}
|
|
85
|
+
if (cur) out.push(cur)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Quita las comillas de FUERA: un `.env` las usa cuando el valor lleva espacios. */
|
|
90
|
+
function unquote (v) {
|
|
91
|
+
const q = v[0]
|
|
92
|
+
if (v.length > 1 && (q === '"' || q === "'") && v.endsWith(q)) return v.slice(1, -1)
|
|
93
|
+
return v
|
|
94
|
+
}
|
package/src/index.js
CHANGED
|
@@ -52,7 +52,7 @@ export { deviceIdOf }
|
|
|
52
52
|
*/
|
|
53
53
|
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
54
54
|
const iss = identity.me?.publickey
|
|
55
|
-
if (!iss) throw new Error('
|
|
55
|
+
if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
|
|
56
56
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
57
57
|
|
|
58
58
|
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
@@ -133,7 +133,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
133
133
|
*/
|
|
134
134
|
async function handleRenew (from, p) {
|
|
135
135
|
const d = p?.data
|
|
136
|
-
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: '
|
|
136
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
137
137
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
138
|
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
139
|
}
|
|
@@ -151,10 +151,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
151
151
|
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
152
152
|
async function handleDevices (from, p) {
|
|
153
153
|
const d = p?.data
|
|
154
|
-
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: '
|
|
154
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
155
155
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
156
156
|
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
|
|
157
|
-
if (!chk.ok) return send(from, { type: MSG.ERROR, error: '
|
|
157
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
158
158
|
const { issued, revoked, revokedCerts } = await identity.listDelegations()
|
|
159
159
|
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
160
160
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
package/src/invite.js
CHANGED
|
@@ -392,16 +392,16 @@ export function parseInvite (text) {
|
|
|
392
392
|
// el original.
|
|
393
393
|
const undoUrl = (s) => { try { return decodeURIComponent(s) } catch { return s } }
|
|
394
394
|
|
|
395
|
-
const
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
398
|
-
if (
|
|
399
|
-
if (
|
|
400
|
-
if (
|
|
395
|
+
const tag = payload[0]
|
|
396
|
+
const rest = payload.slice(1)
|
|
397
|
+
if (tag === FMT_SHORT) { const o = shortDecode(rest); if (o) return o }
|
|
398
|
+
if (tag === FMT_COMPACT) { const o = compactDecode(rest); if (o) return o }
|
|
399
|
+
if (tag === FMT_JSON) { const o = parse(undoUrl(rest)) || parse(rest); if (o) return o }
|
|
400
|
+
if (tag === FMT_B64) { const s = b64urlDecodeStr(rest); const o = s && parse(s); if (o) return o }
|
|
401
401
|
|
|
402
402
|
// --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
403
|
+
const raw = undoUrl(payload)
|
|
404
|
+
if (raw.trimStart().startsWith('{')) return parse(raw)
|
|
405
405
|
const s = b64urlDecodeStr(payload)
|
|
406
406
|
return s ? parse(s) : null
|
|
407
407
|
}
|
package/src/protocol.js
CHANGED
|
@@ -33,6 +33,11 @@ export const MSG = Object.freeze({
|
|
|
33
33
|
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
34
34
|
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
35
35
|
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
36
|
+
// «¿sigo siendo de esta casa?» — la ÚNICA pregunta que se puede hacer SIN certificado:
|
|
37
|
+
// va firmada con la llave del propio aparato, que es lo que el acta nombra. Existe para
|
|
38
|
+
// el aparato que perdió su papel: sin ella no tiene forma de enterarse de que lo echaron.
|
|
39
|
+
CHECK: 'vault.check', // dispositivo → vault: { data:{op:'check',publickey,ts}, signature }
|
|
40
|
+
CHECKED: 'vault.checked', // vault → dispositivo: { in:boolean } — y si no, el REVOKED firmado
|
|
36
41
|
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
37
42
|
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|
|
38
43
|
GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
|
|
@@ -93,3 +98,10 @@ export const SCOPE = Object.freeze({
|
|
|
93
98
|
export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
|
|
94
99
|
export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
|
|
95
100
|
export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Nombre de una variable de entorno: `MAYUSCULAS_CON_GUION_BAJO`, hasta 64. Vive aquí
|
|
104
|
+
* —y no en el cajón que la guarda— porque la comprueban también la TUI, la consola
|
|
105
|
+
* remota y el lector de `.env`, y tres copias de una regla son tres reglas.
|
|
106
|
+
*/
|
|
107
|
+
export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)
|
package/src/service.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
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
25
|
makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig,
|
|
25
26
|
makePairingCode, commitCode, pubkeyId
|
|
@@ -58,7 +59,7 @@ function installNodeGlobals () {
|
|
|
58
59
|
})
|
|
59
60
|
}
|
|
60
61
|
if (typeof globalThis.WebSocket === 'undefined') {
|
|
61
|
-
throw new Error('
|
|
62
|
+
throw new Error('this runtime has no global WebSocket: use Node >=22')
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -70,17 +71,17 @@ function installNodeGlobals () {
|
|
|
70
71
|
* que esto no dice que sea TU bóveda — eso lo dice el código de 6 dígitos, que solo
|
|
71
72
|
* aprende la bóveda donde tú lo tecleas.
|
|
72
73
|
*/
|
|
73
|
-
async function
|
|
74
|
+
async function verifyHello (p, sn) {
|
|
74
75
|
const b = p?.body
|
|
75
|
-
if (!b?.iss || b.sn !== sn) throw new Error('
|
|
76
|
+
if (!b?.iss || b.sn !== sn) throw new Error('the vault answered a different pairing')
|
|
76
77
|
if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
|
|
77
|
-
throw new Error('
|
|
78
|
+
throw new Error('the vault reply is not properly signed')
|
|
78
79
|
}
|
|
79
80
|
// El modo también viene aquí, y aquí viene FIRMADO por la bóveda. Se comprueba
|
|
80
81
|
// de nuevo aunque ya se haya mirado el del QR: en la forma corta el QR es un
|
|
81
82
|
// código que pasó por manos ajenas, y esta es la primera vez que la bóveda
|
|
82
83
|
// dice de su puño y letra qué se propone hacer.
|
|
83
|
-
|
|
84
|
+
rejectAdoption(b.m)
|
|
84
85
|
return b
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -98,11 +99,11 @@ async function verificarHola (p, sn) {
|
|
|
98
99
|
* invitación, en vez de dejar que el viaje termine en un «intent-mismatch» del
|
|
99
100
|
* otro lado que no le explica nada a nadie.
|
|
100
101
|
*/
|
|
101
|
-
function
|
|
102
|
-
if (
|
|
102
|
+
function rejectAdoption (mode) {
|
|
103
|
+
if (mode !== 'adopt') return
|
|
103
104
|
throw new Error(
|
|
104
|
-
'
|
|
105
|
-
'
|
|
105
|
+
'this invitation was opened to ADOPT the device account, and an agent does not transfer its identity: ' +
|
|
106
|
+
'the vault grants it one. Open the pairing without `--adopt` (`dotrino-vault pair --service <ns>`).'
|
|
106
107
|
)
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -113,14 +114,14 @@ function rechazarAdopcion (modo) {
|
|
|
113
114
|
* siempre significa lo mismo para quien lo lee: el código ya se usó o venció, y
|
|
114
115
|
* hay que pedir otro en la bóveda. Se dice así, no con el error crudo.
|
|
115
116
|
*/
|
|
116
|
-
async function
|
|
117
|
-
if (!code) throw new Error('
|
|
117
|
+
async function resolveAppointment (client, code) {
|
|
118
|
+
if (!code) throw new Error('the invitation carries no pairing code')
|
|
118
119
|
if (typeof client.redeemPairingCode !== 'function') {
|
|
119
|
-
throw new Error('
|
|
120
|
+
throw new Error('this proxy does not support pairing codes (update @dotrino/proxy-client)')
|
|
120
121
|
}
|
|
121
122
|
const r = await client.redeemPairingCode(code)
|
|
122
123
|
if (!r?.ok || !r.instance) {
|
|
123
|
-
throw new Error(`
|
|
124
|
+
throw new Error(`that code is no good: ${r?.error || 'not valid'}. Ask the vault for a new one.`)
|
|
124
125
|
}
|
|
125
126
|
return r.instance
|
|
126
127
|
}
|
|
@@ -135,7 +136,7 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
|
135
136
|
// para siempre y waitForSecrets no reintentaría. Le ponemos un timeout propio.
|
|
136
137
|
let timer
|
|
137
138
|
const timeout = new Promise((_, reject) => {
|
|
138
|
-
timer = setTimeout(() => reject(new Error('timeout
|
|
139
|
+
timer = setTimeout(() => reject(new Error('timeout connecting to the proxy')), connectTimeoutMs)
|
|
139
140
|
})
|
|
140
141
|
try {
|
|
141
142
|
await Promise.race([client.connect(), timeout])
|
|
@@ -143,8 +144,8 @@ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
|
143
144
|
try { client.close() } catch (_) {}
|
|
144
145
|
// El 'error' de transporte del cliente puede llegar como un Event sin
|
|
145
146
|
// `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(`
|
|
147
|
+
const why = e?.message || e?.type || 'transport error'
|
|
148
|
+
throw new Error(`could not connect to the proxy ${proxyUrl}: ${why}`)
|
|
148
149
|
} finally {
|
|
149
150
|
clearTimeout(timer)
|
|
150
151
|
}
|
|
@@ -163,7 +164,7 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
|
163
164
|
const off = client.on('message', (_from, payload) => {
|
|
164
165
|
if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
|
|
165
166
|
})
|
|
166
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
167
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for the vault reply')) }, timeoutMs)
|
|
167
168
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
168
169
|
})
|
|
169
170
|
}
|
|
@@ -231,26 +232,26 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
231
232
|
// incluida la vieja, así que esto entiende cualquier invitación.
|
|
232
233
|
if (typeof qr === 'string') {
|
|
233
234
|
const o = parseInvite(qr)
|
|
234
|
-
if (!o) throw new Error('
|
|
235
|
+
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair --service <ns>`)')
|
|
235
236
|
qr = o
|
|
236
237
|
}
|
|
237
|
-
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr
|
|
238
|
-
|
|
239
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
240
|
-
if (!dir) throw new Error('
|
|
241
|
-
label = label || '
|
|
238
|
+
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
|
|
239
|
+
rejectAdoption(qr.m)
|
|
240
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
241
|
+
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
242
|
+
label = label || 'service:' + ns
|
|
242
243
|
|
|
243
244
|
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
244
245
|
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
245
246
|
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
246
247
|
// que se re-pineen a mano.
|
|
247
|
-
const
|
|
248
|
+
const previous = readServiceIdentity(dir)
|
|
248
249
|
let replaced = null
|
|
249
|
-
if (
|
|
250
|
+
if (previous?.device?.publickey) {
|
|
250
251
|
replaced = {
|
|
251
|
-
ns:
|
|
252
|
-
enrolledAt:
|
|
253
|
-
deviceId: (await pubkeyId(
|
|
252
|
+
ns: previous.ns,
|
|
253
|
+
enrolledAt: previous.enrolledAt,
|
|
254
|
+
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
254
255
|
}
|
|
255
256
|
try { onReplace?.(replaced) } catch (_) {}
|
|
256
257
|
}
|
|
@@ -262,17 +263,17 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
262
263
|
// canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
|
|
263
264
|
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
264
265
|
// aunque la bóveda esté en otro proxio de la malla.
|
|
265
|
-
const
|
|
266
|
-
const
|
|
266
|
+
const target = await resolveAppointment(client, qr.conn)
|
|
267
|
+
const hello = await new Promise((resolve, reject) => {
|
|
267
268
|
const off = client.on('message', (_f, p) => {
|
|
268
|
-
if (p?.type === MSG.HELLO_OK) {
|
|
269
|
-
else if (p?.type === MSG.ERROR) {
|
|
269
|
+
if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
|
|
270
|
+
else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
|
|
270
271
|
})
|
|
271
|
-
const t = setTimeout(() => {
|
|
272
|
-
const
|
|
273
|
-
try { client.send(
|
|
272
|
+
const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
|
|
273
|
+
const finish = () => { off(); clearTimeout(t) }
|
|
274
|
+
try { client.send(target, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { finish(); reject(e) }
|
|
274
275
|
})
|
|
275
|
-
qr = { ...qr, iss:
|
|
276
|
+
qr = { ...qr, iss: hello.iss, proxy: hello.proxy || qr.proxy }
|
|
276
277
|
}
|
|
277
278
|
try {
|
|
278
279
|
const device = await makeDeviceKey({ label })
|
|
@@ -294,11 +295,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
294
295
|
const off = client.on('message', (_from, p) => {
|
|
295
296
|
if (!p || typeof p !== 'object') return
|
|
296
297
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
297
|
-
const show = onCode || (({ deviceId, code }) => console.log(`[vault-service]
|
|
298
|
+
const show = onCode || (({ deviceId, code }) => console.log(`[vault-service] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
298
299
|
show({ deviceId, code })
|
|
299
300
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
300
301
|
})
|
|
301
|
-
const t = setTimeout(() => { cleanup(); reject(new Error('timeout
|
|
302
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
|
|
302
303
|
const cleanup = () => { off(); clearTimeout(t) }
|
|
303
304
|
})
|
|
304
305
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
@@ -306,10 +307,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
306
307
|
|
|
307
308
|
// Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
|
|
308
309
|
// para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
|
|
309
|
-
if (res.code !== code) throw new Error('
|
|
310
|
+
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
310
311
|
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
|
|
311
|
-
if (!v.ok) throw new Error('cert
|
|
312
|
-
if (res.cert.iss !== qr.iss) throw new Error('cert
|
|
312
|
+
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
313
|
+
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
313
314
|
|
|
314
315
|
// Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
|
|
315
316
|
// anterior deja de existir en este agente.
|
|
@@ -332,9 +333,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
332
333
|
masterPubkey = masterPubkey || saved?.iss
|
|
333
334
|
device = device || saved?.device
|
|
334
335
|
cert = cert || saved?.cert
|
|
335
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
336
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
|
|
336
337
|
if (!proxyUrl || !masterPubkey || !device || !cert) {
|
|
337
|
-
throw new Error('
|
|
338
|
+
throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
338
339
|
}
|
|
339
340
|
|
|
340
341
|
const client = await freshClient(proxyUrl)
|
|
@@ -366,13 +367,13 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
366
367
|
|
|
367
368
|
// Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
|
|
368
369
|
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('
|
|
370
|
+
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
371
|
+
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
|
|
371
372
|
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
|
|
372
|
-
if (!ok) throw new Error('
|
|
373
|
+
if (!ok) throw new Error('invalid master signature on the secrets reply')
|
|
373
374
|
|
|
374
375
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
375
|
-
if (!payload || typeof payload.secrets !== 'object') throw new Error('
|
|
376
|
+
if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
|
|
376
377
|
return payload.secrets
|
|
377
378
|
} finally { client.close() }
|
|
378
379
|
}
|
|
@@ -388,6 +389,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
388
389
|
* Lo que NO hace: recargar nada. El aviso no trae valores, y la reacción correcta
|
|
389
390
|
* es que el proceso termine y lo levante su supervisor (ver `watchEnv`).
|
|
390
391
|
*
|
|
392
|
+
* NO SE CONFÍA SOLO EN EL AVISO: al (re)conectar, COMPARA. Un aviso es un mensaje y
|
|
393
|
+
* los mensajes se pierden — el agente pudo estar vivo pero incomunicado, y entonces
|
|
394
|
+
* el aviso se encola en el proxio (24 h), llega tarde y lo tira la ventana de
|
|
395
|
+
* frescura (5 min), o caduca en la cola y no llega nunca. En los tres casos el
|
|
396
|
+
* agente se quedaba con la configuración vieja PARA SIEMPRE, porque al reconectar
|
|
397
|
+
* solo volvía a escuchar: nunca preguntaba. Ahora, cada vez que la conexión se
|
|
398
|
+
* restablece, pide el bundle y compara su huella con la que tiene aplicada; si no
|
|
399
|
+
* coincide, reacciona igual que si hubiera llegado el aviso. El aviso es el camino
|
|
400
|
+
* rápido; esto es el que no se pierde.
|
|
401
|
+
*
|
|
402
|
+
* Y lo mismo salva al interruptor de emergencia: si el cert se revocó mientras
|
|
403
|
+
* estaba incomunicado, el `REVOKED` se perdió igual, pero la comparación recibe
|
|
404
|
+
* «unauthorized: revoked» y apaga al agente ahí mismo.
|
|
405
|
+
*
|
|
391
406
|
* Defensas, porque una señal que provoca reinicios es un arma si se descuida:
|
|
392
407
|
* · **Firma de la maestra pineada** y `ns` que coincida. Sin esto, cualquiera
|
|
393
408
|
* reinicia la flota ajena cuando quiera.
|
|
@@ -402,30 +417,53 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
402
417
|
* @param {Object} opts
|
|
403
418
|
* @param {string} opts.dir Identidad del servicio (`service-identity.json`).
|
|
404
419
|
* @param {string} [opts.ns]
|
|
405
|
-
* @param {(info:{ns:string, ts:number})=>void} opts.onChange
|
|
420
|
+
* @param {(info:{ns:string, ts:number, via:'notice'|'reconcile'})=>void} opts.onChange
|
|
421
|
+
* `via` dice por dónde se enteró: `notice` (llegó el aviso) o `reconcile` (nadie
|
|
422
|
+
* avisó y la comparación al reconectar encontró otra configuración).
|
|
406
423
|
* @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
|
|
424
|
+
* @param {Record<string,string>} [opts.applied] El bundle que el agente tiene EN USO.
|
|
425
|
+
* Pasarlo es lo que permite detectar un cambio ya en la PRIMERA conexión — el que
|
|
426
|
+
* ocurrió entre que el agente pidió su configuración y logró ponerse a escuchar. Sin
|
|
427
|
+
* él no se pierde la protección, solo empieza una conexión más tarde: la primera se
|
|
428
|
+
* limita a tomar la referencia.
|
|
407
429
|
* @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
|
|
430
|
+
* La comparación también lo respeta, pero **aplazándose** (el aviso sí se descarta):
|
|
431
|
+
* es lo que impide que un fallo sistemático se convierta en un ciclo de reinicios,
|
|
432
|
+
* porque acota los reinicios por comparación a uno por ventana.
|
|
408
433
|
* @param {number} [opts.minIntervalMs=60000] Mínimo entre dos avisos obedecidos.
|
|
409
434
|
* @param {number} [opts.jitterMs=5000] Espera aleatoria antes de avisar.
|
|
435
|
+
* @param {number} [opts.reconcileMinMs=30000] Mínimo entre dos comparaciones. Sin él,
|
|
436
|
+
* una conexión que va y viene cada cinco segundos le pediría el bundle a la bóveda
|
|
437
|
+
* cada cinco segundos, y son N agentes.
|
|
410
438
|
* @param {(m:string)=>void} [opts.log]
|
|
411
|
-
* @returns {Promise<{stop:()=>void}>}
|
|
439
|
+
* @returns {Promise<{stop:()=>void, reconcile:()=>Promise<boolean>}>}
|
|
440
|
+
* `reconcile()` fuerza la comparación (útil desde un chequeo de salud); devuelve si
|
|
441
|
+
* encontró un cambio.
|
|
412
442
|
*/
|
|
413
443
|
export async function watchSecretsChanges ({
|
|
414
|
-
dir, ns, onChange, onRevoked, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
|
|
444
|
+
dir, ns, onChange, onRevoked, applied, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
|
|
445
|
+
reconcileMinMs = 30000, log = () => {}
|
|
415
446
|
} = {}) {
|
|
416
447
|
const saved = dir ? readServiceIdentity(dir) : null
|
|
417
448
|
ns = ns || saved?.ns
|
|
418
449
|
if (!saved?.device || !saved?.cert || !saved?.iss || !saved?.proxy) {
|
|
419
|
-
throw new Error('
|
|
450
|
+
throw new Error('service not enrolled: nobody to listen to')
|
|
420
451
|
}
|
|
421
452
|
const master = saved.iss
|
|
422
|
-
const
|
|
423
|
-
let
|
|
424
|
-
let
|
|
425
|
-
const
|
|
426
|
-
let
|
|
453
|
+
const bornAt = Date.now()
|
|
454
|
+
let lastTs = 0
|
|
455
|
+
let lastObeyed = 0
|
|
456
|
+
const inFlight = new Set() // avisos cuya firma se está comprobando ahora mismo
|
|
457
|
+
let stopped = false
|
|
427
458
|
let client = null
|
|
428
|
-
let
|
|
459
|
+
let retryTimer = null
|
|
460
|
+
// Huella de la configuración EN USO. Comparar huellas y no valores es lo que permite
|
|
461
|
+
// decir «esto no es lo que está corriendo» sin volver a manejar los secretos.
|
|
462
|
+
let fingerprint = applied === undefined ? null : fingerprintOf(applied)
|
|
463
|
+
let firstConnection = true
|
|
464
|
+
let lastReconcile = 0
|
|
465
|
+
let reconciling = false
|
|
466
|
+
let reconcileRetry = null
|
|
429
467
|
|
|
430
468
|
/**
|
|
431
469
|
* REVOCACIÓN = interruptor de emergencia. Hasta ahora revocar un cert no le
|
|
@@ -433,12 +471,12 @@ export async function watchSecretsChanges ({
|
|
|
433
471
|
* memoria hasta que alguien se acordara de reiniciarlo (el README decía lo
|
|
434
472
|
* contrario). Teniendo la conexión abierta, el aviso llega y el agente se apaga
|
|
435
473
|
* en el acto — y no vuelve, porque al arrancar `fetchSecrets` recibe
|
|
436
|
-
* «
|
|
474
|
+
* «unauthorized: revoked», que no se arregla reintentando.
|
|
437
475
|
*
|
|
438
476
|
* Sin gracia, sin piso y sin jitter, al revés que un cambio de configuración:
|
|
439
477
|
* apagar algo comprometido es justo lo que no debe esperar su turno.
|
|
440
478
|
*/
|
|
441
|
-
const
|
|
479
|
+
const handleRevocation = async (payload) => {
|
|
442
480
|
const body = payload.body
|
|
443
481
|
if (!body || body.op !== 'revoke') return
|
|
444
482
|
// Que sea MI revocación y no la de otro dispositivo del mismo dueño.
|
|
@@ -451,69 +489,157 @@ export async function watchSecretsChanges ({
|
|
|
451
489
|
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
452
490
|
}
|
|
453
491
|
|
|
454
|
-
const
|
|
455
|
-
if (payload?.type === MSG.REVOKED) return
|
|
492
|
+
const handleMessage = async (payload) => {
|
|
493
|
+
if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
|
|
456
494
|
if (payload?.type !== MSG.SECRETS_CHANGED) return
|
|
457
495
|
const body = payload.body
|
|
458
496
|
if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
|
|
459
497
|
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) {
|
|
460
|
-
return log('[vault]
|
|
498
|
+
return log('[vault] change notice dated outside the window: ignored')
|
|
461
499
|
}
|
|
462
|
-
if (body.ts <=
|
|
500
|
+
if (body.ts <= lastTs) return log('[vault] repeated change notice: ignored')
|
|
463
501
|
// 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 `
|
|
502
|
+
// asíncrono: sin esta marca las dos pasarían el corte de `lastTs` antes de
|
|
465
503
|
// que ninguna lo actualizara, y el agente se reiniciaría por partida doble.
|
|
466
|
-
// La marca se pone antes del `await` y el `
|
|
504
|
+
// La marca se pone antes del `await` y el `lastTs` DESPUÉS de verificar, para
|
|
467
505
|
// que un aviso falso con fecha lejana no pueda dejar fuera a los de verdad.
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
let
|
|
506
|
+
if (inFlight.has(body.ts)) return
|
|
507
|
+
inFlight.add(body.ts)
|
|
508
|
+
let valid = false
|
|
471
509
|
try {
|
|
472
|
-
|
|
473
|
-
} finally {
|
|
474
|
-
if (!
|
|
475
|
-
if (body.ts <=
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
if (
|
|
510
|
+
valid = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
|
|
511
|
+
} finally { inFlight.delete(body.ts) }
|
|
512
|
+
if (!valid) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
|
|
513
|
+
if (body.ts <= lastTs) return
|
|
514
|
+
lastTs = body.ts
|
|
515
|
+
|
|
516
|
+
const now = Date.now()
|
|
517
|
+
if (now - bornAt < graceMs) {
|
|
480
518
|
return log('[vault] change notice right after start: ignored (avoids the restart loop)')
|
|
481
519
|
}
|
|
482
|
-
if (
|
|
483
|
-
return log('[vault]
|
|
520
|
+
if (now - lastObeyed < minIntervalMs) {
|
|
521
|
+
return log('[vault] change notice too close to the previous one: ignored')
|
|
484
522
|
}
|
|
485
|
-
|
|
523
|
+
lastObeyed = now
|
|
486
524
|
|
|
487
|
-
const
|
|
488
|
-
log(`[vault] the vault reports config for "${ns}" changed (in ${
|
|
489
|
-
setTimeout(() => { if (!
|
|
525
|
+
const wait = Math.floor(Math.random() * jitterMs)
|
|
526
|
+
log(`[vault] the vault reports config for "${ns}" changed (in ${wait} ms)`)
|
|
527
|
+
setTimeout(() => { if (!stopped) { try { onChange?.({ ns, ts: body.ts, via: 'notice' }) } catch (e) { log('[vault] ' + e.message) } } }, wait)
|
|
490
528
|
}
|
|
491
529
|
|
|
492
|
-
|
|
493
|
-
|
|
530
|
+
/**
|
|
531
|
+
* PREGUNTA en vez de esperar a que le cuenten: pide el bundle y lo compara con el
|
|
532
|
+
* que está en uso. Es la red que recoge todo lo que el aviso deja caer — el que se
|
|
533
|
+
* perdió mientras el agente estaba incomunicado, el que llegó fuera de la ventana de
|
|
534
|
+
* frescura, el que caducó en la cola del proxio y el que el propio agente descartó.
|
|
535
|
+
*
|
|
536
|
+
* Lo que compara son DOS BUNDLES DE LA BÓVEDA, nunca el `.env` contra el bundle. Por
|
|
537
|
+
* eso recibir la configuración por primera vez —tarde, que es como la recibe el
|
|
538
|
+
* proxio— no es un cambio: la referencia es lo que el agente recibió, no lo que tenía
|
|
539
|
+
* antes de recibir nada. Es la razón de fondo por la que esto no puede volverse un
|
|
540
|
+
* ciclo de reinicios; el tope de frecuencia de abajo es el cinturón.
|
|
541
|
+
*
|
|
542
|
+
* No pasa por el piso entre avisos, y es a propósito: ese freno existe porque un aviso
|
|
543
|
+
* es una señal que alguien podría repetir para provocar reinicios. Esto no es una
|
|
544
|
+
* señal, es el estado real firmado por la maestra — si de verdad difiere, reiniciar es
|
|
545
|
+
* siempre lo correcto, y al volver ya coincide.
|
|
546
|
+
*
|
|
547
|
+
* @returns {Promise<boolean>} si encontró (y anunció) un cambio.
|
|
548
|
+
*/
|
|
549
|
+
const reconcile = async (trigger) => {
|
|
550
|
+
if (stopped || reconciling) return false
|
|
551
|
+
if (Date.now() - lastReconcile < reconcileMinMs) return false
|
|
552
|
+
// TOPE DE FRECUENCIA, que es lo único que separa esto de un ciclo de reinicios.
|
|
553
|
+
// Reiniciar por comparación no puede repetirse más de una vez por gracia de
|
|
554
|
+
// arranque: si algo hiciera que la comparación fallara SIEMPRE, el proceso saldría
|
|
555
|
+
// cada 30 s y no cada dos, que es la diferencia entre que el supervisor lo note y
|
|
556
|
+
// que la máquina se pase el día arrancando.
|
|
557
|
+
//
|
|
558
|
+
// Y a diferencia del aviso, aquí no se DESCARTA: se APLAZA. Descartarlo era el
|
|
559
|
+
// defecto que este cambio vino a cerrar, así que reintroducirlo por la puerta de
|
|
560
|
+
// atrás sería el peor final posible.
|
|
561
|
+
const sinceStart = Date.now() - bornAt
|
|
562
|
+
if (sinceStart < graceMs) {
|
|
563
|
+
clearTimeout(reconcileRetry)
|
|
564
|
+
reconcileRetry = setTimeout(() => { reconcile(trigger).catch(() => {}) }, graceMs - sinceStart + 50)
|
|
565
|
+
reconcileRetry.unref?.()
|
|
566
|
+
return false
|
|
567
|
+
}
|
|
568
|
+
reconciling = true
|
|
569
|
+
let bundle = null
|
|
570
|
+
try {
|
|
571
|
+
bundle = await fetchSecrets({ dir, ns })
|
|
572
|
+
} catch (e) {
|
|
573
|
+
// El cert revocado mientras estaba incomunicado: el `REVOKED` firmado se perdió
|
|
574
|
+
// igual que el aviso, y esta es la única otra forma de enterarse. Lo demás (la
|
|
575
|
+
// bóveda apagada, el proxio a medio levantar) es transitorio y se reintenta en la
|
|
576
|
+
// siguiente conexión: no se apaga nada por no haber podido preguntar.
|
|
577
|
+
if (/unauthorized: revoked/.test(e.message)) {
|
|
578
|
+
log('[vault] ⚠ the vault REVOKED this agent cert (noticed on ' + trigger + '): shutting down')
|
|
579
|
+
try { onRevoked?.({ nonce: saved.cert?.nonce || null }) } catch (err) { log('[vault] ' + err.message) }
|
|
580
|
+
} else {
|
|
581
|
+
log('[vault] could not check the config on ' + trigger + ': ' + e.message)
|
|
582
|
+
}
|
|
583
|
+
return false
|
|
584
|
+
} finally {
|
|
585
|
+
reconciling = false
|
|
586
|
+
lastReconcile = Date.now()
|
|
587
|
+
}
|
|
588
|
+
const current = fingerprintOf(bundle)
|
|
589
|
+
if (fingerprint === null) { fingerprint = current; return false } // primera vez: solo tomar referencia
|
|
590
|
+
if (current === fingerprint) return false
|
|
591
|
+
fingerprint = current
|
|
592
|
+
lastObeyed = Date.now()
|
|
593
|
+
log(`[vault] the config for "${ns}" is not the one running (noticed on ${trigger}): the notice never arrived`)
|
|
594
|
+
if (!stopped) { try { onChange?.({ ns, ts: Date.now(), via: 'reconcile' }) } catch (e) { log('[vault] ' + e.message) } }
|
|
595
|
+
return true
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const connect = async () => {
|
|
599
|
+
if (stopped) return
|
|
494
600
|
try {
|
|
495
601
|
client = await freshClient(saved.proxy)
|
|
496
602
|
await identifyAsService(client, saved.device)
|
|
497
|
-
client.on('message', (_from, p) => {
|
|
603
|
+
client.on('message', (_from, p) => { handleMessage(p).catch(() => {}) })
|
|
498
604
|
// Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
|
|
499
605
|
// eso es exactamente el momento en que uno querría enterarse de una rotación.
|
|
500
|
-
client.on('disconnected', () => { if (!
|
|
606
|
+
client.on('disconnected', () => { if (!stopped) retryTimer = setTimeout(connect, 5000) })
|
|
501
607
|
log('[vault] listening for config changes')
|
|
608
|
+
// Y COMPARAR, porque el rato sin conexión es justo cuando se pierde un aviso.
|
|
609
|
+
// También en la PRIMERA conexión, aunque el agente venga de pedir el bundle hace
|
|
610
|
+
// un instante: entre aquello y esto pudo pasar cualquier cosa —si el proxio estaba
|
|
611
|
+
// caído, esta primera conexión llega minutos después— y ahí ya no hay quien avise.
|
|
612
|
+
// Cuesta una consulta por arranque; el hueco que tapa no tiene límite.
|
|
613
|
+
const trigger = firstConnection ? 'startup' : 'reconnect'
|
|
614
|
+
firstConnection = false
|
|
615
|
+
reconcile(trigger).catch(() => {})
|
|
502
616
|
} catch (e) {
|
|
503
|
-
if (!
|
|
617
|
+
if (!stopped) retryTimer = setTimeout(connect, 5000)
|
|
504
618
|
}
|
|
505
619
|
}
|
|
506
|
-
await
|
|
620
|
+
await connect()
|
|
507
621
|
|
|
508
622
|
return {
|
|
509
623
|
stop () {
|
|
510
|
-
|
|
511
|
-
clearTimeout(
|
|
624
|
+
stopped = true
|
|
625
|
+
clearTimeout(retryTimer)
|
|
626
|
+
clearTimeout(reconcileRetry)
|
|
512
627
|
try { client?.close() } catch (_) {}
|
|
513
|
-
}
|
|
628
|
+
},
|
|
629
|
+
reconcile: () => reconcile('demand')
|
|
514
630
|
}
|
|
515
631
|
}
|
|
516
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Huella de un bundle. Las claves van ORDENADAS: el bundle se arma mezclando el cajón
|
|
635
|
+
* del scope con el del aparato, así que el mismo contenido puede llegar en otro orden y
|
|
636
|
+
* un cambio de orden no es un cambio de configuración.
|
|
637
|
+
*/
|
|
638
|
+
function fingerprintOf (secrets) {
|
|
639
|
+
const pairs = Object.entries(secrets || {}).map(([k, v]) => [k, String(v)]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
640
|
+
return createHash('sha256').update(JSON.stringify(pairs)).digest('hex')
|
|
641
|
+
}
|
|
642
|
+
|
|
517
643
|
/**
|
|
518
644
|
* Bucle de arranque de un servicio: pide los secretos y, si el vault no está
|
|
519
645
|
* disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
|
|
@@ -528,7 +654,7 @@ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
528
654
|
} catch (e) {
|
|
529
655
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
530
656
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
|
531
|
-
if (/
|
|
657
|
+
if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
|
|
532
658
|
try { onRetry?.(e, delay) } catch (_) {}
|
|
533
659
|
await new Promise((r) => setTimeout(r, delay))
|
|
534
660
|
delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
|