@dotrino/vaultd 0.11.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,55 @@ 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
+
109
+ /**
110
+ * Canjea la cita del QR y devuelve la instancia a la que apunta.
111
+ *
112
+ * Una cita se quema al usarse y caduca en minutos, así que un error acá casi
113
+ * siempre significa lo mismo para quien lo lee: el código ya se usó o venció, y
114
+ * hay que pedir otro en la bóveda. Se dice así, no con el error crudo.
115
+ */
116
+ async function resolverCita (client, code) {
117
+ if (!code) throw new Error('la invitación no trae código de emparejamiento')
118
+ if (typeof client.redeemPairingCode !== 'function') {
119
+ throw new Error('el proxio no soporta códigos de emparejamiento (actualizá @dotrino/proxy-client)')
120
+ }
121
+ const r = await client.redeemPairingCode(code)
122
+ if (!r?.ok || !r.instance) {
123
+ throw new Error(`ese código no sirve: ${r?.error || 'no válido'}. Pedí uno nuevo en la bóveda.`)
124
+ }
125
+ return r.instance
126
+ }
127
+
80
128
  async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
81
129
  installNodeGlobals()
82
130
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
@@ -122,41 +170,99 @@ function waitForMsg (client, predicate, timeoutMs = 30000) {
122
170
 
123
171
  const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
124
172
 
125
- /** 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
+ */
126
181
  export function readServiceIdentity (dir) {
127
- 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 }
128
186
  }
129
187
 
130
188
  function writeServiceIdentity (dir, obj) {
131
189
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
132
190
  const f = identityFileOf(dir)
133
- 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 })
134
193
  }
135
194
 
136
195
  /**
137
- * Enrola ESTE servicio contra el vault (una sola vez; persiste la identidad).
138
- * En el vault se corre antes `dotrino-vault pair --service <ns>`; el QR/payload
139
- * de ese comando es el `qr` de aquí. Muestra un código por `onCode` (o stdout):
140
- * 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>`).
141
213
  *
142
214
  * @param {Object} opts
143
- * @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.
144
216
  * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
145
217
  * @param {string} opts.dir Dónde persistir `service-identity.json`.
146
218
  * @param {string} [opts.label]
147
219
  * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
148
- * @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}>}
149
223
  */
150
- export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
151
- 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
+ }
152
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)
153
239
  if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
154
240
  if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
155
241
  label = label || 'servicio:' + ns
156
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
+
157
258
  const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
158
259
  // QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
159
260
  if (!qr.iss) {
261
+ // `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
262
+ // canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
263
+ // que la emitió —lo dice el prefijo del propio código—, así que funciona
264
+ // aunque la bóveda esté en otro proxio de la malla.
265
+ const destino = await resolverCita(client, qr.conn)
160
266
  const hola = await new Promise((resolve, reject) => {
161
267
  const off = client.on('message', (_f, p) => {
162
268
  if (p?.type === MSG.HELLO_OK) { fin(); verificarHola(p, qr.sn).then(resolve, reject) }
@@ -164,7 +270,7 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
164
270
  })
165
271
  const t = setTimeout(() => { fin(); reject(new Error('la bóveda no contestó: ese código pudo caducar')) }, 15000)
166
272
  const fin = () => { off(); clearTimeout(t) }
167
- try { client.send(qr.conn, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
273
+ try { client.send(destino, { type: MSG.HELLO, sn: qr.sn }) } catch (e) { fin(); reject(e) }
168
274
  })
169
275
  qr = { ...qr, iss: hola.iss, proxy: hola.proxy || qr.proxy }
170
276
  }
@@ -177,7 +283,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
177
283
  // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
178
284
  // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
179
285
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
180
- 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() }
181
291
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
182
292
 
183
293
  const enrolled = new Promise((resolve, reject) => {
@@ -201,8 +311,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
201
311
  if (!v.ok) throw new Error('cert inválido: ' + v.reason)
202
312
  if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
203
313
 
314
+ // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
315
+ // anterior deja de existir en este agente.
204
316
  writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
205
- return { device, cert: res.cert, iss: qr.iss }
317
+ return { device, cert: res.cert, iss: qr.iss, replaced }
206
318
  } finally { client.close() }
207
319
  }
208
320
 
@@ -265,6 +377,143 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
265
377
  } finally { client.close() }
266
378
  }
267
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
+
268
517
  /**
269
518
  * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
270
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.11.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.36.0",
23
- "@dotrino/proxy-client": "0.8.0",
22
+ "@dotrino/identity": "^0.38.0",
23
+ "@dotrino/proxy-client": "^0.10.0",
24
24
  "ws": "^8.18.0"
25
25
  },
26
26
  "license": "MIT",
@@ -38,5 +38,8 @@
38
38
  "README.md",
39
39
  "vendor",
40
40
  "lib/src"
41
- ]
41
+ ],
42
+ "devDependencies": {
43
+ "@dotrino/remote-agent": "^0.3.0"
44
+ }
42
45
  }
package/src/atrest.js CHANGED
Binary file
package/src/client.js CHANGED
@@ -36,7 +36,7 @@ function waitFor (client, predicate, timeoutMs = 30000) {
36
36
  const off = client.on('message', (_from, payload) => {
37
37
  if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
38
38
  })
39
- const t = setTimeout(() => { cleanup(); reject(new Error('timeout 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