@dotrino/vaultd 0.12.0 → 0.13.0

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