@dotrino/vault 0.16.0 → 0.18.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 +23 -2
- package/package.json +1 -1
- package/src/admin.js +146 -0
- package/src/atrest.js +0 -0
- package/src/enroll.js +29 -29
- package/src/index.js +70 -18
- package/src/protocol.js +15 -1
- package/src/sealed.js +1 -1
- package/src/service.js +21 -9
package/README.md
CHANGED
|
@@ -234,16 +234,37 @@ pineada en el enrolamiento.
|
|
|
234
234
|
|
|
235
235
|
`startDeviceVault(identity, { proxyUrl? }) → Promise<handle>`
|
|
236
236
|
|
|
237
|
-
- `startPairing({ scope?, ttlMs?, label? }) → { qr, expiresInMs }`
|
|
237
|
+
- `startPairing({ scope?, ttlMs?, label?, mode?, account? }) → { qr, expiresInMs }`
|
|
238
|
+
- `stopPairing(token)`
|
|
238
239
|
- `listPending() → [{ deviceId, label }]`
|
|
239
240
|
- `approve(deviceId, code) → Promise<{ ok, deviceId }>` (code = lo que muestra el dispositivo)
|
|
240
241
|
- `reject(deviceId)`
|
|
241
242
|
- `listMachines() → Promise<[{ sub, deviceId, label, scope, exp, nonce }]>`
|
|
242
243
|
- `revoke(nonce) → Promise`
|
|
243
244
|
- `getSelfCert() → Promise<cert>` (self-cert `P ← P`, para actuar además de cliente)
|
|
244
|
-
- `onPendingChange(fn)`, `close()`
|
|
245
|
+
- `onPendingChange(fn)`, `onAdopted(fn)`, `close()`
|
|
245
246
|
|
|
246
247
|
Cripto y firma: `@dotrino/identity`. Transporte: `@dotrino/proxy-client`. No reimplementa
|
|
247
248
|
nada del ecosistema.
|
|
248
249
|
|
|
250
|
+
### Qué atiende, y qué NO
|
|
251
|
+
|
|
252
|
+
Esta bóveda **no es** el daemon del PC: comparte el núcleo de enrolamiento
|
|
253
|
+
(`lib/src/enroll.js`, el mismo archivo), pero atiende menos mensajes del protocolo.
|
|
254
|
+
|
|
255
|
+
Atiende: `vault.hello` (la llave que pide el QR corto), `vault.enroll` +
|
|
256
|
+
`vault.acta.sealed` (enrolar y adoptar), `vault.renew` (**renovación automática** del
|
|
257
|
+
cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30 días) y
|
|
258
|
+
`vault.devices` (lista + revocados, con re-emisión del `REVOKED` firmado).
|
|
259
|
+
|
|
260
|
+
**No** atiende, y hoy solo existen contra el daemon `dotrino-vault`:
|
|
261
|
+
|
|
262
|
+
| Falta | Qué implica |
|
|
263
|
+
|---|---|
|
|
264
|
+
| `vault.sign` | una máquina no puede pedirle a la maestra que firme por ella |
|
|
265
|
+
| `vault.store` / `vault.get` | no hay store centralizado, ni edición de perfil, ni clave de contenido |
|
|
266
|
+
| `vault.secrets` | `@dotrino/vault/config` (el reemplazo del `.env`) **no funciona** contra un dispositivo |
|
|
267
|
+
| `vault.admin` | sin consola remota |
|
|
268
|
+
| bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
|
|
269
|
+
|
|
249
270
|
MIT · parte de [Dotrino](https://dotrino.com).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/admin.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin.js — CONSOLA REMOTA: administrar el perfil desde un dispositivo emparejado.
|
|
3
|
+
*
|
|
4
|
+
* Diseño: `dotrino-vault/docs/consola-remota.md`. Módulo PURO (sin `node:*`, sin red,
|
|
5
|
+
* sin disco), igual que `enroll.js`, para que lo puedan usar el daemon del PC y «este
|
|
6
|
+
* dispositivo es bóveda» sin duplicar la regla.
|
|
7
|
+
*
|
|
8
|
+
* QUÉ SE DELEGA Y QUÉ NO — esto es el módulo entero, el resto es plomería:
|
|
9
|
+
*
|
|
10
|
+
* sí · ver el acta y la bitácora · iniciar un emparejamiento (mostrar el QR)
|
|
11
|
+
* · APROBAR o rechazar a quien entra · REVOCAR a un miembro
|
|
12
|
+
* no · cambiar permisos · traspasar el mando · conceder `admin`
|
|
13
|
+
* · nada de los secretos de servicios
|
|
14
|
+
*
|
|
15
|
+
* La frontera no es un capricho: un admin puede **admitir y expulsar**, pero no
|
|
16
|
+
* reescribir quién manda. Así un aparato con `admin` robado hace daño **acotado y
|
|
17
|
+
* reversible** (se le revoca) en vez de poder traspasarse el mando y dejar al dueño
|
|
18
|
+
* fuera de su propia cuenta, que no tiene vuelta atrás. Las operaciones que no se
|
|
19
|
+
* delegan **no existen como mensaje**: no hay nada que autorizar mal.
|
|
20
|
+
*
|
|
21
|
+
* POR QUÉ APROBAR A DISTANCIA NO DEBILITA EL EMPAREJAMIENTO: el código de 6 dígitos es
|
|
22
|
+
* un COMPROMISO (`enroll.js`) — lo genera y lo MUESTRA el aparato que entra, y la bóveda
|
|
23
|
+
* solo firma si el código tecleado lo recompone. Aprobar exige haber leído la pantalla
|
|
24
|
+
* del aparato nuevo, se haga desde el PC o desde el teléfono. Lo que cambia es dónde
|
|
25
|
+
* está el humano, no qué tiene que demostrar.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
|
|
29
|
+
export const ADMIN_OPS = Object.freeze(['pending', 'pair', 'approve', 'reject', 'revoke', 'audit'])
|
|
30
|
+
|
|
31
|
+
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
32
|
+
export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
|
|
33
|
+
|
|
34
|
+
/** Tope de entradas de bitácora por petición. */
|
|
35
|
+
export const AUDIT_MAX = 500
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {Object} o
|
|
39
|
+
* @param {Object} o.desk mostrador de emparejamiento (`createEnrollDesk`).
|
|
40
|
+
* @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
|
|
41
|
+
* @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
|
|
42
|
+
* @param {(pub:string)=>Promise<string>} o.deviceIdOf
|
|
43
|
+
* @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
|
|
44
|
+
* @param {(op:string, info?:object)=>void} [o.audit]
|
|
45
|
+
* @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
|
|
46
|
+
* @param {number} [o.ttlMs] vida del cert que se emita.
|
|
47
|
+
*/
|
|
48
|
+
export function createAdminDesk ({
|
|
49
|
+
desk, verify, readActivity = () => [], deviceIdOf,
|
|
50
|
+
notify = async () => {}, audit = () => {},
|
|
51
|
+
defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
|
|
52
|
+
ttlMs, now = () => Date.now()
|
|
53
|
+
}) {
|
|
54
|
+
const ops = new Set(ADMIN_OPS)
|
|
55
|
+
|
|
56
|
+
// NONCE de un solo uso. `sign`/`get` son idempotentes y les basta la ventana de ±5
|
|
57
|
+
// min; `approve` y `revoke` CAMBIAN ESTADO, así que reproducir uno dentro de esa
|
|
58
|
+
// ventana sí importa (re-aprobar un enrolamiento que el dueño ya rechazó, por
|
|
59
|
+
// ejemplo). Por eso el nonce, y por eso es obligatorio en todas las ops: una lista
|
|
60
|
+
// de excepciones es una invitación a equivocarse.
|
|
61
|
+
const seen = new Map()
|
|
62
|
+
function nonceAlreadyUsed (nonce) {
|
|
63
|
+
const t = now()
|
|
64
|
+
for (const [n, exp] of seen) if (exp <= t) seen.delete(n)
|
|
65
|
+
if (seen.has(nonce)) return true
|
|
66
|
+
seen.set(nonce, t + ADMIN_NONCE_TTL_MS)
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Atiende una petición ya verificada como *fresca*. Devuelve `{ ok, result }` o
|
|
72
|
+
* `{ ok: false, error }` — quien llama se encarga de responder por el transporte.
|
|
73
|
+
*/
|
|
74
|
+
async function handle (data, { signature, cert } = {}) {
|
|
75
|
+
if (!data || !ops.has(data.op)) return { ok: false, error: 'admin: invalid operation' }
|
|
76
|
+
if (typeof data.nonce !== 'string' || data.nonce.length < 16) {
|
|
77
|
+
return { ok: false, error: 'admin: missing single-use nonce' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const chk = await verify({ data, signature, cert })
|
|
81
|
+
if (!chk?.ok) {
|
|
82
|
+
audit('rejected', { what: 'admin', op: data.op, reason: chk?.reason })
|
|
83
|
+
return { ok: false, error: 'unauthorized: ' + (chk?.reason || 'cert') }
|
|
84
|
+
}
|
|
85
|
+
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
86
|
+
|
|
87
|
+
// El nonce se marca DESPUÉS de autorizar: si no, cualquiera podría quemarle los
|
|
88
|
+
// nonces a un admin legítimo mandando basura firmada por nadie.
|
|
89
|
+
if (nonceAlreadyUsed(data.nonce)) {
|
|
90
|
+
audit('rejected', { what: 'admin', op: data.op, by, reason: 'replay' })
|
|
91
|
+
return { ok: false, error: 'admin: nonce already used' }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
if (data.op === 'pending') return { ok: true, result: { pending: desk.listPending() } }
|
|
96
|
+
|
|
97
|
+
if (data.op === 'audit') {
|
|
98
|
+
const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
|
|
99
|
+
return { ok: true, result: { entries: readActivity(limit) } }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (data.op === 'pair') {
|
|
103
|
+
// Un admin NO empareja servicios ni crea otros admins. Se corta aquí, en la
|
|
104
|
+
// bóveda, no en la interfaz: una pantalla no es un control de seguridad.
|
|
105
|
+
const scope = Array.isArray(data.scope) && data.scope.length ? data.scope : defaultScope
|
|
106
|
+
const forbidden = scope.find((s) => s === 'vault:admin' || String(s).startsWith('vault:secrets:'))
|
|
107
|
+
if (forbidden) {
|
|
108
|
+
audit('rejected', { what: 'admin', op: 'pair', by, reason: 'forbidden-scope', scope: forbidden })
|
|
109
|
+
return { ok: false, error: 'admin: cannot grant admin or service secrets from here; do that on the vault machine' }
|
|
110
|
+
}
|
|
111
|
+
const label = String(data.label || '').slice(0, 60) || 'remoto'
|
|
112
|
+
const r = await desk.startPairing({ scope, label, ...(ttlMs ? { ttlMs } : {}) })
|
|
113
|
+
audit('admin.pair', { by })
|
|
114
|
+
return { ok: true, result: r }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (data.op === 'approve') {
|
|
118
|
+
const r = await desk.approve(String(data.code || ''), { deviceId: data.deviceId })
|
|
119
|
+
audit('admin.approve', { by, device: data.deviceId || null })
|
|
120
|
+
await notify('enrolled', { deviceId: r?.deviceId || data.deviceId || null, by })
|
|
121
|
+
return { ok: true, result: r || { ok: true } }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (data.op === 'reject') {
|
|
125
|
+
desk.reject(data.deviceId)
|
|
126
|
+
audit('admin.reject', { by, device: data.deviceId || null })
|
|
127
|
+
return { ok: true, result: { ok: true } }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (data.op === 'revoke') {
|
|
131
|
+
const r = await desk.revoke(String(data.certNonce || ''))
|
|
132
|
+
audit('admin.revoke', { by, certNonce: data.certNonce })
|
|
133
|
+
await notify('revoked', { certNonce: data.certNonce, by })
|
|
134
|
+
return { ok: true, result: r || { ok: true } }
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
audit('rejected', { what: 'admin', op: data.op, by, reason: e.message })
|
|
138
|
+
return { ok: false, error: e.message }
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, error: 'admin: invalid operation' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { handle, get nonceCount () { return seen.size } }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default { createAdminDesk, ADMIN_OPS, ADMIN_NONCE_TTL_MS }
|
package/src/atrest.js
ADDED
|
Binary file
|
package/src/enroll.js
CHANGED
|
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
|
|
55
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
|
|
56
56
|
export const scopeToCaps = (scope) =>
|
|
57
57
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
58
|
|
|
@@ -119,15 +119,15 @@ export function createEnrollDesk ({
|
|
|
119
119
|
// único que necesita el QR corto para que el aparato le hable punto a punto.
|
|
120
120
|
connToken = null
|
|
121
121
|
} = {}) {
|
|
122
|
-
if (!identity) throw new Error('createEnrollDesk:
|
|
123
|
-
if (!iss) throw new Error('createEnrollDesk:
|
|
122
|
+
if (!identity) throw new Error('createEnrollDesk: missing identity')
|
|
123
|
+
if (!iss) throw new Error('createEnrollDesk: missing iss (master pubkey)')
|
|
124
124
|
|
|
125
125
|
// token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
|
|
126
126
|
// state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
|
|
127
127
|
const pending = new Map()
|
|
128
128
|
|
|
129
129
|
const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
|
|
130
|
-
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault]
|
|
130
|
+
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
|
|
131
131
|
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
132
132
|
|
|
133
133
|
/**
|
|
@@ -196,7 +196,7 @@ export function createEnrollDesk ({
|
|
|
196
196
|
const pend = pending.get(String(p?.sn || ''))
|
|
197
197
|
if (!pend || Date.now() > pend.exp) {
|
|
198
198
|
audit('rejected', { what: 'hello', reason: 'sin-sesion' })
|
|
199
|
-
return reply(from, { type: MSG_ERROR, error: 'no
|
|
199
|
+
return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
|
|
200
200
|
}
|
|
201
201
|
// La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
|
|
202
202
|
// la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
|
|
@@ -215,19 +215,19 @@ export function createEnrollDesk ({
|
|
|
215
215
|
async function handleEnroll (from, p) {
|
|
216
216
|
const d = p?.data
|
|
217
217
|
if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
|
|
218
|
-
return reply(from, { type: MSG_ERROR, error: 'enroll
|
|
218
|
+
return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
|
|
219
219
|
}
|
|
220
220
|
const pend = pending.get(d.token)
|
|
221
221
|
if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
|
|
222
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
222
|
+
return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
|
|
223
223
|
}
|
|
224
|
-
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: '
|
|
224
|
+
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
|
|
225
225
|
// V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
|
|
226
226
|
// bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
|
|
227
227
|
// humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
|
|
228
228
|
const intent = d.intent || 'join'
|
|
229
229
|
if (intent !== 'join' && intent !== 'adopt') {
|
|
230
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
230
|
+
return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
|
|
231
231
|
}
|
|
232
232
|
if (intent !== (pend.mode || 'join')) {
|
|
233
233
|
audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
|
|
@@ -235,22 +235,22 @@ export function createEnrollDesk ({
|
|
|
235
235
|
}
|
|
236
236
|
if (!isFresh(d)) {
|
|
237
237
|
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
238
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
238
|
+
return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
239
239
|
}
|
|
240
240
|
// PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
|
|
241
241
|
if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
|
|
242
242
|
audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
|
|
243
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
243
|
+
return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
|
|
244
244
|
}
|
|
245
245
|
// El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
|
|
246
246
|
// y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
|
|
247
247
|
if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
|
|
248
248
|
audit('rejected', { what: 'enroll', reason: 'no-commit' })
|
|
249
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
249
|
+
return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
|
|
250
250
|
}
|
|
251
251
|
// Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
|
|
252
252
|
if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
|
|
253
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
253
|
+
return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
const deviceId = await deviceIdOf(d.dpub)
|
|
@@ -289,16 +289,16 @@ export function createEnrollDesk ({
|
|
|
289
289
|
*/
|
|
290
290
|
async function approve (code, { deviceId } = {}) {
|
|
291
291
|
code = String(code || '').trim()
|
|
292
|
-
if (!code) throw new Error('
|
|
292
|
+
if (!code) throw new Error('missing code (the digits shown by the device)')
|
|
293
293
|
|
|
294
294
|
let pend
|
|
295
295
|
if (deviceId) {
|
|
296
296
|
pend = findPending(deviceId)
|
|
297
|
-
if (!pend) throw new Error('no
|
|
297
|
+
if (!pend) throw new Error('no device awaiting approval with that id')
|
|
298
298
|
} else {
|
|
299
299
|
const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
|
|
300
|
-
if (waiting.length === 0) throw new Error('no
|
|
301
|
-
if (waiting.length > 1) throw new Error('
|
|
300
|
+
if (waiting.length === 0) throw new Error('no device awaiting approval')
|
|
301
|
+
if (waiting.length > 1) throw new Error('more than one pairing in flight; restart it with dotrino-vault pair')
|
|
302
302
|
pend = waiting[0]
|
|
303
303
|
}
|
|
304
304
|
|
|
@@ -306,8 +306,8 @@ export function createEnrollDesk ({
|
|
|
306
306
|
const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
|
|
307
307
|
if (expected !== pend.commit) {
|
|
308
308
|
audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
|
|
309
|
-
log('[vault]
|
|
310
|
-
throw new Error('
|
|
309
|
+
log('[vault] wrong code for %s: no certificate was issued', pend.deviceId)
|
|
310
|
+
throw new Error('code does not match the one shown by the device: no certificate was issued. Check it and try again.')
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
// CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
|
|
@@ -319,7 +319,7 @@ export function createEnrollDesk ({
|
|
|
319
319
|
pend.state = 'AWAITING_ACTA'
|
|
320
320
|
pend.approvedAt = Date.now()
|
|
321
321
|
reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
|
|
322
|
-
log('[vault]
|
|
322
|
+
log('[vault] adoption approved for %s: waiting for the sealed record', pend.deviceId)
|
|
323
323
|
fire(onPendingChange)
|
|
324
324
|
return { ok: true, deviceId: pend.deviceId, adopting: true }
|
|
325
325
|
}
|
|
@@ -369,24 +369,24 @@ export function createEnrollDesk ({
|
|
|
369
369
|
async function handleActaSealed (from, p) {
|
|
370
370
|
const acta = p?.acta
|
|
371
371
|
const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
|
|
372
|
-
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no
|
|
373
|
-
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: '
|
|
372
|
+
if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
|
|
373
|
+
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
|
|
374
374
|
if (acta.sealer !== iss) {
|
|
375
375
|
audit('rejected', { what: 'adopt', reason: 'not-sealer' })
|
|
376
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
376
|
+
return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
|
|
377
377
|
}
|
|
378
378
|
if (acta.sealedBy !== pend.dpub) {
|
|
379
379
|
audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
|
|
380
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
380
|
+
return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
|
|
381
381
|
}
|
|
382
382
|
if (pend.profileId && acta.profileId !== pend.profileId) {
|
|
383
383
|
audit('rejected', { what: 'adopt', reason: 'other-profile' })
|
|
384
|
-
return reply(from, { type: MSG_ERROR, error: '
|
|
384
|
+
return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
|
|
385
385
|
}
|
|
386
386
|
|
|
387
387
|
try {
|
|
388
388
|
const r = await identity.joinProfile(acta)
|
|
389
|
-
if (!r?.joined) throw new Error(r?.reason || '
|
|
389
|
+
if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
|
|
390
390
|
audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
|
|
391
391
|
// El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
|
|
392
392
|
// dos quedan en la misma versión.
|
|
@@ -400,7 +400,7 @@ export function createEnrollDesk ({
|
|
|
400
400
|
return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
|
|
401
401
|
} catch (e) {
|
|
402
402
|
log('[vault] no se pudo adoptar la cuenta: %s', e.message)
|
|
403
|
-
reply(pend.from, { type: MSG_ERROR, error: '
|
|
403
|
+
reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
|
|
404
404
|
return { ok: false, error: e.message }
|
|
405
405
|
}
|
|
406
406
|
}
|
|
@@ -411,7 +411,7 @@ export function createEnrollDesk ({
|
|
|
411
411
|
? findPending(deviceId)
|
|
412
412
|
: [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
|
|
413
413
|
if (!pend) return { ok: false }
|
|
414
|
-
reply(pend.from, { type: MSG_ERROR, error: '
|
|
414
|
+
reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
|
|
415
415
|
pending.delete(pend.token)
|
|
416
416
|
audit('reject', { device: pend.deviceId })
|
|
417
417
|
fire(onPendingChange)
|
|
@@ -428,7 +428,7 @@ export function createEnrollDesk ({
|
|
|
428
428
|
const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
|
|
429
429
|
const { signature } = await identity.signData(body)
|
|
430
430
|
try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
|
|
431
|
-
catch (e) { log('[vault]
|
|
431
|
+
catch (e) { log('[vault] could not emit revoke:', e.message) }
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
/** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
|
package/src/index.js
CHANGED
|
@@ -27,16 +27,13 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
|
+
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
|
+
// local se queda corta, el dispositivo deja de atender mensajes sin que nadie lo note.
|
|
32
|
+
import { MSG, SCOPE } from './protocol.js'
|
|
30
33
|
|
|
31
|
-
const SIGN_SCOPE =
|
|
34
|
+
const SIGN_SCOPE = SCOPE.SIGN
|
|
32
35
|
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
33
|
-
|
|
34
|
-
const MSG = {
|
|
35
|
-
ENROLL: 'vault.enroll',
|
|
36
|
-
DEVICES: 'vault.devices',
|
|
37
|
-
DEVICES_RESULT: 'vault.devices.result',
|
|
38
|
-
ERROR: 'vault.error'
|
|
39
|
-
}
|
|
36
|
+
const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
|
|
40
37
|
|
|
41
38
|
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
42
39
|
export { deviceIdOf }
|
|
@@ -49,10 +46,11 @@ export { deviceIdOf }
|
|
|
49
46
|
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
50
47
|
* @param {object} [opts]
|
|
51
48
|
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
52
|
-
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing,
|
|
53
|
-
* listPending, listMachines, revoke, getSelfCert, onPendingChange,
|
|
49
|
+
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
|
|
50
|
+
* approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
|
|
51
|
+
* onAdopted, close }
|
|
54
52
|
*/
|
|
55
|
-
export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
53
|
+
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
56
54
|
const iss = identity.me?.publickey
|
|
57
55
|
if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
|
|
58
56
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
@@ -67,12 +65,17 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
67
65
|
return cert
|
|
68
66
|
}
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
// `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
|
|
69
|
+
// levanta el del ecosistema — no hay otro transporte.
|
|
70
|
+
const client = injectedClient || await (async () => {
|
|
71
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
72
|
+
const c = new WebSocketProxyClient({
|
|
73
|
+
url: proxy, enableWebRTC: false, autoReconnect: true,
|
|
74
|
+
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
75
|
+
})
|
|
76
|
+
await c.connect()
|
|
77
|
+
return c
|
|
78
|
+
})()
|
|
76
79
|
|
|
77
80
|
const selfCert = await getSelfCert()
|
|
78
81
|
const identify = async () => {
|
|
@@ -87,6 +90,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
87
90
|
const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
|
|
88
91
|
|
|
89
92
|
let _onPendingChange = () => {}
|
|
93
|
+
let _onAdopted = () => {}
|
|
90
94
|
|
|
91
95
|
// ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
|
|
92
96
|
// copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
|
|
@@ -99,9 +103,49 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
99
103
|
sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
|
|
100
104
|
defaultScope: [SIGN_SCOPE],
|
|
101
105
|
defaultTtlMs: DEVICE_TTL_MS,
|
|
106
|
+
// Camino A (la cuenta del aparato pasa a vivir aquí): sin la llave de cifrado, esta
|
|
107
|
+
// bóveda entraría mandando una cuenta cuyo contenido no puede abrir.
|
|
108
|
+
encPub: identity.me?.encryptionPubkey || null,
|
|
109
|
+
vaultLabel: 'bóveda',
|
|
110
|
+
// Cita del proxio para la invitación corta (QR). Si el proxio es viejo y no las
|
|
111
|
+
// conoce, el desk se cae solo a la invitación larga.
|
|
112
|
+
connToken: async () => {
|
|
113
|
+
try { return (await client.requestPairingCode())?.code || null }
|
|
114
|
+
catch (_) { return null }
|
|
115
|
+
},
|
|
116
|
+
onAdopted: (info) => { try { _onAdopted(info) } catch (_) {} },
|
|
102
117
|
onPendingChange: () => _onPendingChange()
|
|
103
118
|
})
|
|
104
119
|
|
|
120
|
+
/** Nonces revocados, para que un cert revocado no pase ningún `verifyChain`. */
|
|
121
|
+
async function revocationSet () {
|
|
122
|
+
const { revoked } = await identity.listDelegations()
|
|
123
|
+
return new Set((revoked || []).map((r) => r.nonce || r))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* RENOVACIÓN automática (igual que `dotrino-vault#handleRenew`): un dispositivo con
|
|
128
|
+
* cert VIGENTE y no revocado pide uno fresco —misma sub-clave y scope— sin QR ni
|
|
129
|
+
* aprobación: sigue siendo el mismo dispositivo, solo extiende la ventana. Un cert
|
|
130
|
+
* vencido o revocado NO se renueva (ahí toca re-emparejar con aprobación).
|
|
131
|
+
*
|
|
132
|
+
* Sin esto, toda máquina enrolada contra un dispositivo-bóveda caduca a los 30 días.
|
|
133
|
+
*/
|
|
134
|
+
async function handleRenew (from, p) {
|
|
135
|
+
const d = p?.data
|
|
136
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
|
|
137
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
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
|
+
}
|
|
140
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
|
|
141
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
142
|
+
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
143
|
+
const { issued } = await identity.listDelegations()
|
|
144
|
+
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
145
|
+
const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
|
|
146
|
+
send(from, { type: MSG.RENEWED, cert })
|
|
147
|
+
}
|
|
148
|
+
|
|
105
149
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
106
150
|
// de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
|
|
107
151
|
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
@@ -124,7 +168,12 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
124
168
|
|
|
125
169
|
client.on('message', (_from, p) => {
|
|
126
170
|
if (!p || typeof p !== 'object') return
|
|
127
|
-
|
|
171
|
+
// El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
|
|
172
|
+
if (p.type === MSG.HELLO) Promise.resolve(desk.handleHello(_from, p)).catch(() => {})
|
|
173
|
+
else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
|
|
174
|
+
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
175
|
+
else if (p.type === MSG.ACTA_SEALED) Promise.resolve(desk.handleActaSealed(_from, p)).catch(() => {})
|
|
176
|
+
else if (p.type === MSG.RENEW) handleRenew(_from, p).catch(() => {})
|
|
128
177
|
else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
|
|
129
178
|
})
|
|
130
179
|
|
|
@@ -148,6 +197,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
148
197
|
return {
|
|
149
198
|
iss, proxy, client,
|
|
150
199
|
startPairing: desk.startPairing,
|
|
200
|
+
stopPairing: desk.stopPairing,
|
|
151
201
|
// Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
|
|
152
202
|
// el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
|
|
153
203
|
approve: (deviceId, code) => desk.approve(code, { deviceId }),
|
|
@@ -159,6 +209,8 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
159
209
|
revoke: (nonce) => desk.revoke(nonce),
|
|
160
210
|
getSelfCert,
|
|
161
211
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
212
|
+
/** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
|
|
213
|
+
onAdopted (fn) { _onAdopted = fn || (() => {}) },
|
|
162
214
|
close () { try { client.close() } catch (_) {} }
|
|
163
215
|
}
|
|
164
216
|
}
|
package/src/protocol.js
CHANGED
|
@@ -59,6 +59,16 @@ export const MSG = Object.freeze({
|
|
|
59
59
|
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
60
60
|
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
61
61
|
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
|
|
62
|
+
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
63
|
+
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
64
|
+
// Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
|
|
65
|
+
// NO se exponen aquí, y no es un olvido — es el límite (§2 del diseño).
|
|
66
|
+
ADMIN: 'vault.admin', // admin → vault: { data:{op,…,ts,nonce}, signature, cert }
|
|
67
|
+
ADMIN_RESULT: 'vault.admin.result', // vault → admin: { op, result }
|
|
68
|
+
// Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
|
|
69
|
+
// contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
|
|
70
|
+
// invisible para el resto de tus dispositivos.
|
|
71
|
+
ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
|
|
62
72
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
63
73
|
})
|
|
64
74
|
|
|
@@ -66,7 +76,11 @@ export const MSG = Object.freeze({
|
|
|
66
76
|
export const SCOPE = Object.freeze({
|
|
67
77
|
SIGN: 'vault:sign', // pedir a la maestra que firme datos (identidad)
|
|
68
78
|
READ: 'vault:read', // leer nodos del árbol de contenidos
|
|
69
|
-
STORE: 'vault:store'
|
|
79
|
+
STORE: 'vault:store', // leer/escribir el store de hilos + aperturas del usuario
|
|
80
|
+
// Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
|
|
81
|
+
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
82
|
+
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
83
|
+
ADMIN: 'vault:admin'
|
|
70
84
|
})
|
|
71
85
|
|
|
72
86
|
/**
|
package/src/sealed.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function seal ({ ek, payload }) {
|
|
|
76
76
|
*/
|
|
77
77
|
export async function openSealed ({ privateKey, enc }) {
|
|
78
78
|
if (!enc || typeof enc.epk !== 'string' || typeof enc.iv !== 'string' || typeof enc.ct !== 'string') {
|
|
79
|
-
throw new Error('
|
|
79
|
+
throw new Error('invalid sealed envelope')
|
|
80
80
|
}
|
|
81
81
|
const key = await deriveAesKey(privateKey, enc.epk)
|
|
82
82
|
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(enc.iv) }, key, fromB64(enc.ct))
|
package/src/service.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
28
28
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
29
29
|
import { parseInvite } from './invite.js'
|
|
30
|
+
import { atRestFor } from './atrest.js'
|
|
30
31
|
|
|
31
32
|
const IDENTITY_FILE = 'service-identity.json'
|
|
32
33
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
@@ -169,15 +170,26 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
|
169
170
|
|
|
170
171
|
const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
|
|
171
172
|
|
|
172
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null.
|
|
175
|
+
*
|
|
176
|
+
* CIFRADA EN REPOSO (`atrest.js`) con una clave ligada a ESTA máquina: el archivo
|
|
177
|
+
* lleva la llave privada del dispositivo, así que copiarlo a otro equipo no sirve.
|
|
178
|
+
* Un archivo de una versión anterior (en claro) se lee igual y queda cifrado en la
|
|
179
|
+
* primera escritura.
|
|
180
|
+
*/
|
|
173
181
|
export function readServiceIdentity (dir) {
|
|
174
|
-
try {
|
|
182
|
+
try {
|
|
183
|
+
const text = fs.readFileSync(identityFileOf(dir), 'utf8')
|
|
184
|
+
return JSON.parse(atRestFor(dir).decrypt(text))
|
|
185
|
+
} catch (_) { return null }
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
function writeServiceIdentity (dir, obj) {
|
|
178
189
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
179
190
|
const f = identityFileOf(dir)
|
|
180
|
-
|
|
191
|
+
const blob = atRestFor(dir).encrypt(JSON.stringify(obj, null, 2))
|
|
192
|
+
fs.writeFileSync(f, blob, { mode: 0o600 })
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
/**
|
|
@@ -433,9 +445,9 @@ export async function watchSecretsChanges ({
|
|
|
433
445
|
if (body.sub !== saved.device.publickey) return
|
|
434
446
|
if (saved.cert?.nonce && body.nonce !== saved.cert.nonce) return
|
|
435
447
|
if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
|
|
436
|
-
return log('[vault]
|
|
448
|
+
return log('[vault] revocation notice BADLY SIGNED: ignored')
|
|
437
449
|
}
|
|
438
|
-
log('[vault] ⚠
|
|
450
|
+
log('[vault] ⚠ the vault REVOKED this agent cert: shutting down')
|
|
439
451
|
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
440
452
|
}
|
|
441
453
|
|
|
@@ -459,13 +471,13 @@ export async function watchSecretsChanges ({
|
|
|
459
471
|
try {
|
|
460
472
|
valida = await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature })
|
|
461
473
|
} finally { enVuelo.delete(body.ts) }
|
|
462
|
-
if (!valida) return log('[vault]
|
|
474
|
+
if (!valida) return log('[vault] change notice BADLY SIGNED: ignored (not from your vault)')
|
|
463
475
|
if (body.ts <= ultimoTs) return
|
|
464
476
|
ultimoTs = body.ts
|
|
465
477
|
|
|
466
478
|
const ahora = Date.now()
|
|
467
479
|
if (ahora - nacido < graceMs) {
|
|
468
|
-
return log('[vault]
|
|
480
|
+
return log('[vault] change notice right after start: ignored (avoids the restart loop)')
|
|
469
481
|
}
|
|
470
482
|
if (ahora - ultimoObedecido < minIntervalMs) {
|
|
471
483
|
return log('[vault] aviso de cambio demasiado seguido del anterior: ignorado')
|
|
@@ -473,7 +485,7 @@ export async function watchSecretsChanges ({
|
|
|
473
485
|
ultimoObedecido = ahora
|
|
474
486
|
|
|
475
487
|
const espera = Math.floor(Math.random() * jitterMs)
|
|
476
|
-
log(`[vault]
|
|
488
|
+
log(`[vault] the vault reports config for "${ns}" changed (in ${espera} ms)`)
|
|
477
489
|
setTimeout(() => { if (!parado) { try { onChange?.({ ns, ts: body.ts }) } catch (e) { log('[vault] ' + e.message) } } }, espera)
|
|
478
490
|
}
|
|
479
491
|
|
|
@@ -486,7 +498,7 @@ export async function watchSecretsChanges ({
|
|
|
486
498
|
// Reconectar solo: si se cae el proxio, el agente deja de ser avisable, y
|
|
487
499
|
// eso es exactamente el momento en que uno querría enterarse de una rotación.
|
|
488
500
|
client.on('disconnected', () => { if (!parado) reintento = setTimeout(conectar, 5000) })
|
|
489
|
-
log('[vault]
|
|
501
|
+
log('[vault] listening for config changes')
|
|
490
502
|
} catch (e) {
|
|
491
503
|
if (!parado) reintento = setTimeout(conectar, 5000)
|
|
492
504
|
}
|