@dotrino/vaultd 0.12.0 → 0.14.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.14.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
@@ -249,6 +249,68 @@ function cmdReject (deviceId) {
249
249
  console.log('Rechazado %s.', deviceId)
250
250
  }
251
251
 
252
+ /**
253
+ * `dotrino-vault me` — el PERFIL del usuario tal como lo tiene la bóveda: apodo, foto y
254
+ * datos. Es lo que editas en cualquier dispositivo emparejado y se sincroniza aquí, así
255
+ * que sirve para comprobar que lo que cambiaste en el aparato llegó de verdad.
256
+ *
257
+ * Distinto de `members` (quién es del perfil) y de `profile` (los perfiles del PC): esto
258
+ * es el CONTENIDO, no la identidad.
259
+ *
260
+ * La foto no se imprime —es un data-URI de hasta ~90 KB— sino que se resume;
261
+ * `me --foto <archivo>` la escribe en disco para poder mirarla.
262
+ */
263
+ async function cmdMe (args = []) {
264
+ const i = args.findIndex((a) => a === '--foto' || a === '--photo')
265
+ const avatarPath = i >= 0 ? args[i + 1] : null
266
+ if (i >= 0 && !avatarPath) { console.error('uso: dotrino-vault me --foto <archivo>'); process.exit(2) }
267
+
268
+ const s = requireDaemon()
269
+ const meFile = path.join(dir, 'me.json')
270
+ try { fs.rmSync(meFile, { force: true }) } catch (_) {}
271
+ writeReq('me-request.json', { ...(avatarPath ? { avatarPath: path.resolve(avatarPath) } : {}) })
272
+ avisar(s.pid, 'SIGUSR2')
273
+ let dump = null
274
+ for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
275
+ // El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
276
+ try { fs.rmSync(meFile, { force: true }) } catch (_) {}
277
+ if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
278
+
279
+ const me = dump.me
280
+ if (!me) {
281
+ console.log('\nTodavía no hay perfil en esta bóveda.')
282
+ console.log('Edita tu nombre o tu foto en un dispositivo emparejado y vuelve a mirar.\n')
283
+ return
284
+ }
285
+
286
+ const cuando = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
287
+ console.log('\n%sPerfil%s · actualizado %s\n', B, Z, cuando)
288
+ console.log(' nombre : %s', me.nickname || '(sin nombre)')
289
+ console.log(' foto : %s', me.avatar
290
+ ? `sí · ${me.avatar.type || 'desconocido'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
291
+ : 'no')
292
+
293
+ // Los campos estándar. `visible` es del usuario: teléfono y dirección nacen ocultos.
294
+ const STD = [['nombres', 'nombres'], ['apellidos', 'apellidos'], ['email', 'correo'],
295
+ ['telefono', 'teléfono'], ['direccion', 'dirección']]
296
+ const puestos = STD.filter(([k]) => me[k])
297
+ if (puestos.length) {
298
+ console.log('')
299
+ for (const [k, etiqueta] of puestos) {
300
+ console.log(' %s: %s%s', etiqueta.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
301
+ }
302
+ }
303
+ for (const [titulo, lista] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
304
+ if (!Array.isArray(lista) || !lista.length) continue
305
+ console.log('\n %s:', titulo)
306
+ for (const x of lista) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
307
+ }
308
+
309
+ if (dump.avatarGuardada) console.log('\n Foto escrita en: %s', dump.avatarGuardada)
310
+ else if (me.avatar) console.log('\n Para verla: dotrino-vault me --foto ~/perfil.png')
311
+ console.log('')
312
+ }
313
+
252
314
  /**
253
315
  * `dotrino-vault members` — el ACTA del perfil: qué llaves son tuyas y qué puede hacer cada
254
316
  * una. Es la misma información que muestra la consola de vault.dotrino.com.
@@ -264,7 +326,7 @@ async function cmdMembers () {
264
326
  if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
265
327
  if (!acta.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
266
328
 
267
- const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves' }
329
+ const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
268
330
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
269
331
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
270
332
  // misma que se enseña al emparejar y en la lista de miembros.
@@ -280,7 +342,9 @@ async function cmdMembers () {
280
342
  const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
281
343
  console.log(' %s %s%s\n %s', m.id, quien, marcas.length ? ' [' + marcas.join(' · ') + ']' : '', caps)
282
344
  }
283
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee')
345
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
346
+ console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
347
+ console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
284
348
  console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
285
349
  }
286
350
 
@@ -288,10 +352,13 @@ async function cmdMembers () {
288
352
  async function cmdCaps (args = []) {
289
353
  const [id, ...cambios] = args
290
354
  if (!id || !cambios.length) {
291
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee')
355
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
292
356
  process.exit(2)
293
357
  }
294
- const NOMBRE = { firma: 'sign', guarda: 'store', lee: 'read', sign: 'sign', store: 'store', read: 'read' }
358
+ const NOMBRE = {
359
+ firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
360
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin'
361
+ }
295
362
  const s = requireDaemon()
296
363
  const actaFile = path.join(dataDir(), 'acta.json')
297
364
  try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
@@ -579,8 +646,9 @@ function help () {
579
646
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
580
647
  reject <deviceId> rechaza un dispositivo pendiente
581
648
  devices lista dispositivos enrolados / revocados
649
+ me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
582
650
  members el acta del perfil: quién es tuyo y qué puede hacer
583
- caps <ID> ±permiso cambia permisos (+firma -guarda …)
651
+ caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
584
652
  revoke <nonce> revoca un dispositivo (le ordena autoborrarse)
585
653
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
586
654
  logs últimos logs del servicio
@@ -623,6 +691,7 @@ export async function runCtl (argv) {
623
691
  case 'approve': return cmdApprove(rest[0])
624
692
  case 'reject': return cmdReject(rest[0])
625
693
  case 'devices': return cmdDevices()
694
+ case 'me': return cmdMe(rest)
626
695
  case 'members': return cmdMembers()
627
696
  case 'caps': return cmdCaps(rest)
628
697
  case 'revoke': return cmdRevoke(rest[0])