@dotrino/vaultd 0.26.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -239,9 +239,16 @@ dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma
239
239
  dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
240
240
  dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
241
241
  dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
242
- dotrino-vault secret set <ns> <CLAVE> <valor> # guarda un secreto para ese servicio
243
- dotrino-vault secret rm <ns> <CLAVE> # borra un secreto
244
- dotrino-vault secret list # nombres de secretos (nunca valores)
242
+ dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
243
+ # aparatos que sirven ese namespace
244
+ dotrino-vault secret set <ns> CLAVE=valor CLAVE2=valor2 … # VARIAS de una vez (un solo aviso)
245
+ dotrino-vault secret import <ns> [archivo.env] # lo mismo desde un .env (o por la entrada estándar)
246
+ dotrino-vault secret rm <ns> <CLAVE> # borra una variable del scope
247
+ dotrino-vault secret device set <ID> <CLAVE> <valor> # variable de UN aparato: solo la lee él
248
+ dotrino-vault secret device set <ID> CLAVE=valor … # varias de una vez
249
+ dotrino-vault secret device import <ID> [archivo.env]
250
+ dotrino-vault secret device rm <ID> <CLAVE> # borra una variable de ese aparato
251
+ dotrino-vault secret list # los dos cajones, por nombre (nunca valores)
245
252
  dotrino-vault logs # últimas 40 líneas del servicio (journalctl; solo donde hay systemd)
246
253
  dotrino-vault version # versión instalada (status avisa si el daemon quedó viejo)
247
254
  ```
@@ -253,6 +260,70 @@ su compromiso— y lo aprende cuando lo tecleas. El que sí recibe `deviceId` es
253
260
  El `ns` de un secreto va en minúsculas (`[a-z0-9-]`, hasta 32), la clave en
254
261
  MAYÚSCULAS_CON_GUION_BAJO (hasta 64) y el valor es texto de hasta 8 KB.
255
262
 
263
+ #### Cargar la configuración de un servicio: JUNTA, no de una en una
264
+
265
+ Guardar una variable hace que la bóveda **avise al servicio de que su configuración
266
+ cambió**, y el servicio **sale** para que su supervisor lo levante y la lea entera y
267
+ fresca (`watchEnv`, más abajo). Cargándolas de una en una, seis variables son **seis
268
+ avisos**: el servicio obedece el primero y arranca con lo que hubiera puesto en ese
269
+ momento mientras tú sigues escribiendo el resto — configuración a medias, y encima
270
+ parece que funcionó.
271
+
272
+ Por eso cargar configuración es **una sola orden**:
273
+
274
+ ```sh
275
+ dotrino-vault secret import proxy .env # el .env que ya tienes
276
+ cat .env | dotrino-vault secret import proxy # o por la entrada estándar
277
+ dotrino-vault secret set proxy TURN_KEY_ID=k-123 DB_URL=postgres://… # o a mano, juntas
278
+ ```
279
+
280
+ Se leen `CLAVE=valor` (comentarios con `#`, `export` delante, comillas alrededor del
281
+ valor). **Todo o nada**: si una línea está mal, no se guarda ninguna y se dice cuál —
282
+ media configuración aplicada es peor que ninguna. Un `#` **a mitad de línea no corta el
283
+ valor** (una contraseña puede llevarlo), y una clave repetida es un error, no «gana la
284
+ última».
285
+
286
+ Lo mismo desde la **TUI** (tecla `i` en Variables) y desde la **consola remota**, donde
287
+ se edita todo lo que haga falta y se confirma con **un solo botón**.
288
+
289
+ #### Las variables de entorno se ponen en DOS SITIOS
290
+
291
+ | Dónde | Quién la lee | Para qué |
292
+ |---|---|---|
293
+ | **Por scope** — `secret set <ns> …` | todos los aparatos del perfil que sirven ese namespace | lo que es igual lo corra quien lo corra: la llave de la API, la URL de la base |
294
+ | **Por aparato** — `secret device set <ID> …` | solo ese aparato | lo que cambia de máquina a máquina: el puerto, la URL pública, el nombre del nodo |
295
+
296
+ Al servicio se le entrega **un solo bundle**: el del scope con el suyo **encima**. Si
297
+ una variable está en los dos, **manda la del aparato** — lo específico gana, igual que
298
+ un `.env` de máquina sobre el general. Así dos servidores sirven el mismo `ns` sin
299
+ tener que partirlo en `proxy-1` y `proxy-2` para cambiar un puerto.
300
+
301
+ Lo del aparato se indexa por su llave, que es la misma que firma la petición: no hay
302
+ forma de pedir lo de otro. Solo se le pueden poner a un **servicio** (un miembro con
303
+ nombre de servicio en el acta): un teléfono no pide bundles, así que guardárselas sería
304
+ configuración muerta. Y **al quitar el aparato se van con él**.
305
+
306
+ #### Pública o privada: si el VALOR puede salir de esta máquina
307
+
308
+ Cada variable, esté en el cajón que esté, es **pública** o **privada**, y eso decide una
309
+ sola cosa: si su valor puede viajar hacia la **consola remota** (`vault.dotrino.com`, un
310
+ aparato tuyo con permiso de administrar). Al servicio que la lee le da igual: recibe las
311
+ dos.
312
+
313
+ ```sh
314
+ dotrino-vault secret set web PUBLIC_URL https://ejemplo.com --public
315
+ dotrino-vault secret set web API_KEY sk-… # sin bandera: privada
316
+ dotrino-vault secret visibility web PUBLIC_URL private # cambiarlo sin tocar el valor
317
+ ```
318
+
319
+ - **Se nace privada.** Y **rotar el valor conserva la visibilidad**: exponer un secreto
320
+ tiene que ser una decisión, no el efecto colateral de un `set`.
321
+ - Desde la consola remota se ve el **nombre** de todas y el **valor solo de las públicas**;
322
+ a cualquiera se le puede poner un valor nuevo **a ciegas** (rotar una llave que no puedes
323
+ leer es justo para lo que sirve), y **borrar** no se delega. Lo que viaja va **cifrado**
324
+ con la clave de contenido del perfil: el proxy no ve nada. Detalle y límites en
325
+ [`docs/consola-remota.md`](./docs/consola-remota.md).
326
+
256
327
  ### Interfaz de terminal (TUI)
257
328
 
258
329
  Las bóvedas, los dispositivos y los secretos también se manejan desde una **interfaz
@@ -285,13 +356,25 @@ dispositivos/variables que estás viendo:
285
356
  código que muestra el dispositivo, **rechazar** y **revocar**.
286
357
  - **Scopes y variables (secretos):** ver los scopes y sus variables (nunca los
287
358
  valores), **agregar** una variable (con su scope) y **quitar** una variable o un
288
- scope entero.
359
+ scope entero. Son las **compartidas**: las de UN aparato se ponen en la otra
360
+ pestaña, y esta lo dice en vez de repetirlas.
361
+
362
+ Y dentro de **Dispositivos**, con `e` sobre un servicio, sus **variables propias**:
363
+ las que lee solo él y le ganan a las del scope que se llamen igual. Cada cajón se
364
+ administra donde ya elegiste lo que lo distingue — el namespace en su pestaña, el
365
+ aparato en la lista de aparatos. En los dos, `t` cambia si la variable es **pública**
366
+ (su valor se puede ver desde la consola remota) o **privada**.
289
367
 
290
368
  Al emparejar, la bóveda **pregunta primero a qué cuenta entra el dispositivo** y
291
- recién después muestra el QR, que además dice de qué cuenta salió. La pregunta ofrece
292
- una tercera opción, «adoptar la cuenta que trae el dispositivo», que aparece
293
- **desactivada**: el dispositivo todavía no sabe entregar la suya. Desde la CLI ese
294
- camino ya se inicia con `dotrino-vault pair --adopt`.
369
+ recién después muestra el QR, que además dice de qué cuenta salió. Se responde de tres
370
+ formas: entrar a la cuenta activa, estrenar una nueva, o **conectar un servicio**
371
+ (pide el namespace —`proxy`, `geo`…— y emite la invitación con scope
372
+ `vault:secrets:<ns>`, igual que `pair --service`; el QR avisa de que lo que entrega es
373
+ un servicio y no un aparato del dueño). Es lo que hace falta para que ese aparato
374
+ luego tenga variables propias: sin `cn`, la bóveda no se las guarda. Una cuarta
375
+ opción, «adoptar la cuenta que trae el dispositivo», aparece **desactivada**: el
376
+ dispositivo todavía no sabe entregar la suya. Desde la CLI ese camino ya se inicia con
377
+ `dotrino-vault pair --adopt`.
295
378
 
296
379
  `Esc` desde las pestañas vuelve a la lista de bóvedas; `q` sale desde cualquier
297
380
  pantalla, salvo mientras escribes en un campo o respondes una confirmación: ahí se
@@ -354,35 +437,45 @@ Si ya usabas el vault antes de esto, tu identidad de siempre se convierte sola e
354
437
  primer perfil («Perfil 1»): la misma llave, los mismos dispositivos, nada que volver
355
438
  a emparejar.
356
439
 
357
- ### Contraseña del perfil (opcional)
440
+ ### Contraseña del perfil (opcional) — el candado es de ESTA CONSOLA
441
+
442
+ Cada perfil puede llevar contraseña. Con el perfil **bloqueado**, desde la máquina de la
443
+ bóveda no se puede **ver ni tocar nada suyo**: ni sus dispositivos, ni sus variables, ni
444
+ el acta, ni tus datos, ni la bitácora — y tampoco emparejar, aprobar, quitar o guardar
445
+ una variable. La CLI y la TUI contestan «bóveda bloqueada» hasta que alguien teclee la
446
+ contraseña.
358
447
 
359
- Cada perfil puede llevar contraseña. **Solo se pide para EDITAR el perfil** (cambiar
360
- tu nombre, avatar o datos): tus dispositivos siguen firmando, leyendo y guardando
361
- aunque el perfil esté bloqueado — así un reinicio del PC nunca deja tus apps muertas
362
- esperando a que alguien teclee algo.
448
+ Lo que **no** cambia es el servicio: **tus dispositivos ya emparejados siguen firmando,
449
+ leyendo y guardando** aunque esté bloqueado. Eso viaja por el proxy, no por esta consola,
450
+ y así un reinicio del PC nunca deja tus apps muertas esperando a que alguien teclee algo.
363
451
 
364
452
  ```sh
365
453
  dotrino-vault profile password # pone o cambia la contraseña (te la pregunta)
366
454
  dotrino-vault profile password rm # la quita
367
- dotrino-vault unlock # desbloquea para poder editar
368
- dotrino-vault lock # vuelve a bloquear
455
+ dotrino-vault unlock # abre la bóveda en esta consola
456
+ dotrino-vault lock # vuelve a cerrarla
369
457
  ```
370
458
 
459
+ Lo único que se sigue viendo con el candado puesto es que **existe** y que está cerrada
460
+ (`status`, `profile ls`): si no, no habría forma de saber qué abrir.
461
+
371
462
  El perfil se vuelve a bloquear al reiniciar el servicio. La contraseña **no se
372
463
  guarda**: solo un verificador con sal (PBKDF2), igual que el candado del navegador.
373
464
  Tiene un mínimo de 4 caracteres y, tras 5 intentos fallidos, cada intento nuevo
374
465
  espera cada vez más (hasta 5 minutos); la cuenta de fallos se guarda, así que
375
466
  reiniciar no la borra.
376
467
 
377
- Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha:
378
- `profile rename`, `profile rm` y `profile password` fallan con «perfil bloqueado» y
379
- hay que correr `dotrino-vault unlock` antes. (La TUI sí la pide sola cuando hace
380
- falta.)
468
+ Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha: cualquier
469
+ comando que mire o toque esa bóveda falla con «perfil bloqueado» y hay que correr
470
+ `dotrino-vault unlock` antes. La TUI sí la pide sola: al **entrar** a una bóveda cerrada
471
+ (y antes de enseñar nada) te pregunta la contraseña.
381
472
 
382
- Para que quede claro qué protege y qué no: evita que otro que se siente en tu máquina
383
- o un dispositivo tuyo comprometido— te reescriba el perfil; **no** cifra la llave en
384
- el disco (de eso se encarga el cifrado en reposo, más abajo, que hoy tampoco usa la
385
- contraseña).
473
+ Para que quede claro qué protege y qué no. **Protege la consola**: que otro que se siente
474
+ en tu máquina vea o toque lo que hay en esa bóveda. **No** cifra la llave en el disco —de
475
+ eso se encarga el cifrado en reposo, más abajo, que hoy no usa la contraseña—, así que
476
+ alguien con acceso a esta máquina como tu usuario o como root sigue pudiendo leer los
477
+ archivos por su cuenta. Es un candado de la puerta por la que se administra, no una
478
+ imposibilidad criptográfica.
386
479
 
387
480
  ## Emparejar un aparato
388
481
 
@@ -463,8 +556,8 @@ La excepción, dicha sin adornos: la sincronización del **perfil**
463
556
  (`profileSet`/`profileGet`) todavía viaja **sin cifrar**, y una operación que llegue
464
557
  en claro se guarda en claro. Es deuda, no diseño.
465
558
 
466
- El candado de contraseña solo bloquea **editar el perfil**: guardar y leer contenido
467
- siguen funcionando con el perfil bloqueado.
559
+ El candado de contraseña cierra **esta consola** (ver arriba): guardar y leer contenido
560
+ desde tus dispositivos siguen funcionando con el perfil bloqueado.
468
561
 
469
562
  (El otro store, el «árbol de contenidos» de `vault.json`, es hoy un esqueleto: se
470
563
  puede leer con `vault.get`, pero ningún mensaje del protocolo escribe nodos.)
@@ -601,6 +694,30 @@ secreto no se puede borrar de la memoria (los strings son inmutables, no hay
601
694
  `zeroize`) y una llave se rota casi siempre *porque se filtró*: un proceso nuevo
602
695
  empieza con el heap limpio.
603
696
 
697
+ **Y no se fía del aviso: al conectar, COMPARA.** Un aviso es un mensaje, y los mensajes
698
+ se pierden. Si el agente está vivo pero incomunicado —se cayó el proxio, se fue la red—
699
+ el aviso se le encola; si el corte pasa de **5 minutos** lo descarta al llegar (ventana
700
+ de frescura) y si pasa de **24 h** ni llega (caduca en la cola). Antes ahí se acababa:
701
+ al reconectar volvía a *escuchar*, nunca preguntaba, y se quedaba con la configuración
702
+ vieja para siempre mientras el log decía «ignorado» como si estuviera bien. Ahora, en
703
+ **cada conexión**, el agente pide su bundle y compara la huella con la que tiene
704
+ aplicada; si no coincide, reacciona igual que con el aviso. El aviso sigue siendo el
705
+ camino rápido —de segundos—; la comparación es el que no se pierde. Cubre también los
706
+ avisos que el propio agente descarta por la gracia de arranque o el piso entre avisos, y
707
+ la **revocación** que ocurrió mientras estaba incomunicado (la comparación recibe *no
708
+ autorizado: revoked* y lo apaga). Comparar tiene su propio piso —`reconcileMinMs`, 30 s—
709
+ para que una conexión que va y viene no le pida el bundle a la bóveda cada cinco
710
+ segundos.
711
+
712
+ **Y no puede volverse un ciclo de reinicios.** Lo que se compara son **dos bundles de la
713
+ bóveda**, nunca el `.env` contra el bundle: la referencia es lo que el agente recibió, así
714
+ que *recibir la configuración por primera vez* —tarde, que es como la recibe el proxio—
715
+ no es un cambio. Encima, un reinicio por comparación no puede repetirse más de una vez
716
+ por **gracia de arranque**: dentro de esos 30 s la comparación **se aplaza** (a
717
+ diferencia del aviso, que ahí sí se descarta), de modo que ni un fallo sistemático
718
+ lograría más de un reinicio cada 30 s — tiempo de sobra para que el supervisor lo marque
719
+ como inestable en vez de que la máquina se pase el día arrancando.
720
+
604
721
  **Modos de fallo:**
605
722
 
606
723
  - **Vault caído / proxy caído** → **espera** (reintento con backoff, para siempre).
@@ -57,16 +57,16 @@ if (process.argv.includes('--tui')) {
57
57
  // escribiendo sus logs en stdout, se los pinta ENCIMA y la deja ilegible. Se guardan
58
58
  // y se sueltan al salir, para no perder un error por el camino.
59
59
  const real = { log: console.log, error: console.error, warn: console.warn }
60
- const guardados = []
61
- const capturar = (nivel) => (...a) => { guardados.push([nivel, a]) }
62
- console.log = capturar('log'); console.error = capturar('error'); console.warn = capturar('warn')
60
+ const buffered = []
61
+ const capture = (level) => (...a) => { buffered.push([level, a]) }
62
+ console.log = capture('log'); console.error = capture('error'); console.warn = capture('warn')
63
63
 
64
64
  const { runTui } = await import('../src/tui/app.js')
65
65
  try {
66
66
  await runTui() // al salir de la TUI, se para todo: es la misma ventana
67
67
  } finally {
68
68
  Object.assign(console, real)
69
- for (const [nivel, a] of guardados) real[nivel](...a)
69
+ for (const [level, a] of buffered) real[level](...a)
70
70
  }
71
71
  process.exit(0)
72
72
  }
package/lib/README.md CHANGED
@@ -50,7 +50,7 @@ si la máquina se compromete, revocas el cert y no había nada que robar.
50
50
  ```bash
51
51
  # en el VAULT (tu PC): abres el emparejamiento del servicio y cargas sus secretos
52
52
  dotrino-vault pair --service miapp # invitación con scope SOLO vault:secrets:miapp
53
- dotrino-vault secret set miapp API_KEY sk-…
53
+ dotrino-vault secret set miapp API_KEY sk-… # la comparten TODAS las máquinas del ns
54
54
 
55
55
  # en el PROYECTO/servidor: enrola esta máquina (pega la invitación)
56
56
  npx dotrino-env enroll --ns miapp
@@ -60,6 +60,17 @@ npx dotrino-env enroll --ns miapp
60
60
  dotrino-vault approve 7K3F-92Q1
61
61
  ```
62
62
 
63
+ Si la misma app corre en **varias máquinas**, lo que cambia de una a otra (el puerto,
64
+ la URL pública) va en el cajón **por aparato**, sin partir el `ns`:
65
+
66
+ ```bash
67
+ dotrino-vault devices # el ID del aparato: AB12-CD34
68
+ dotrino-vault secret device set AB12-CD34 PORT 8443 # solo la lee ESA máquina
69
+ ```
70
+
71
+ Llegan **mezcladas en el mismo bundle** —y por lo tanto en el mismo `process.env`—:
72
+ las del scope, con las del aparato **encima** si se llaman igual.
73
+
63
74
  El código lo **genera el servicio** y **no viaja** por la red: el vault solo puede
64
75
  echarlo de vuelta si un humano lo tipeó. Así, un vault falso no puede enrolarte y
65
76
  aprobar a ciegas no enrola a nadie. Queda `~/.dotrino/service/<ns>/service-identity.json`
@@ -162,7 +173,7 @@ Salir en vez de recargar, por tres razones — y la primera es la de peso:
162
173
  queda corta, falla en silencio.
163
174
  3. **Es un interruptor de emergencia.** Revocar el cert de un agente ya no espera a
164
175
  que alguien se acuerde de reiniciarlo: recibe el `REVOKED` firmado, se apaga, y
165
- al arrancar `fetchSecrets` recibe «no autorizado: revoked», que no se arregla
176
+ al arrancar `fetchSecrets` recibe «unauthorized: revoked», que no se arregla
166
177
  reintentando. Antes, revocar no le quitaba nada a un proceso ya corriendo.
167
178
 
168
179
  Defensas, porque una señal que provoca reinicios es un arma si se descuida: firma
package/lib/src/admin.js CHANGED
@@ -9,8 +9,16 @@
9
9
  *
10
10
  * sí · ver el acta y la bitácora · iniciar un emparejamiento (mostrar el QR)
11
11
  * · APROBAR o rechazar a quien entra · REVOCAR a un miembro
12
+ * · VARIABLES DE ENTORNO: crearlas y darles valor (de un scope o de un aparato),
13
+ * y ver el valor de las marcadas PÚBLICAS
12
14
  * no · cambiar permisos · traspasar el mando · conceder `admin`
13
- * · nada de los secretos de servicios
15
+ * · ver el valor de una variable PRIVADA · borrar variables
16
+ *
17
+ * SOBRE LAS VARIABLES, que es la rendija más nueva: lo que cruza la frontera no es «los
18
+ * secretos» sino los que su dueño MARCÓ como mostrables. Una privada se puede reescribir
19
+ * a ciegas desde la consola, pero su valor no sale de la máquina de la bóveda ni para un
20
+ * aparato tuyo con `admin`. Y los valores que sí salen viajan CIFRADOS con la clave de
21
+ * contenido del perfil (quien llama sella y abre): el proxy transporta y no ve nada.
14
22
  *
15
23
  * La frontera no es un capricho: un admin puede **admitir y expulsar**, pero no
16
24
  * reescribir quién manda. Así un aparato con `admin` robado hace daño **acotado y
@@ -26,7 +34,15 @@
26
34
  */
27
35
 
28
36
  /** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
29
- export const ADMIN_OPS = Object.freeze(['pending', 'pair', 'approve', 'reject', 'revoke', 'audit'])
37
+ export const ADMIN_OPS = Object.freeze([
38
+ 'pending', 'pair', 'approve', 'reject', 'revoke', 'audit',
39
+ // Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
40
+ // ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
41
+ // no debe poder dejar sin configuración a los servicios.
42
+ // `var.setMany` es la MISMA operación con varias variables dentro de un solo sobre: no
43
+ // añade permisos, quita reinicios (ver el enrutado abajo).
44
+ 'vars', 'var.set', 'var.setMany'
45
+ ])
30
46
 
31
47
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
32
48
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
@@ -40,13 +56,18 @@ export const AUDIT_MAX = 500
40
56
  * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
41
57
  * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
42
58
  * @param {(pub:string)=>Promise<string>} o.deviceIdOf
59
+ * @param {{list:(a:object)=>Promise<any>, set:(a:object)=>Promise<any>, setMany?:(a:object)=>Promise<any>}} [o.vars]
60
+ * mostrador de VARIABLES DE ENTORNO. Va inyectado porque aquí no hay cripto ni disco:
61
+ * quien lo implementa (la bóveda) es quien sella con la clave de contenido del perfil y
62
+ * quien decide qué valor puede salir. Sin él, las ops de variables responden que esta
63
+ * bóveda no las atiende, en vez de fingir que se aplicaron.
43
64
  * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
44
65
  * @param {(op:string, info?:object)=>void} [o.audit]
45
66
  * @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
46
67
  * @param {number} [o.ttlMs] vida del cert que se emita.
47
68
  */
48
69
  export function createAdminDesk ({
49
- desk, verify, readActivity = () => [], deviceIdOf,
70
+ desk, verify, readActivity = () => [], deviceIdOf, vars = null,
50
71
  notify = async () => {}, audit = () => {},
51
72
  defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
52
73
  ttlMs, now = () => Date.now()
@@ -127,6 +148,70 @@ export function createAdminDesk ({
127
148
  return { ok: true, result: { ok: true } }
128
149
  }
129
150
 
151
+ // VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
152
+ // qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
153
+ if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany') {
154
+ if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
155
+ // Un destino y solo uno: o un scope, o un aparato. Sin esto, mandar los dos dejaría
156
+ // que quien llama adivine dónde acabó su variable.
157
+ // Booleanos a propósito: comparar los VALORES («proxy» vs una pubkey) nunca da
158
+ // igual, así que mandar los dos destinos se colaba por el hueco.
159
+ const toScope = typeof data.ns === 'string' && !!data.ns
160
+ const toDevice = typeof data.pub === 'string' && !!data.pub
161
+ if (data.op === 'vars') {
162
+ const result = await vars.list({ by })
163
+ audit('admin.vars', { by })
164
+ return { ok: true, result }
165
+ }
166
+ if (toScope === toDevice) return { ok: false, error: 'admin: var.set needs exactly one target (ns or pub)' }
167
+
168
+ // VARIAS DE UNA VEZ. Mismo permiso y misma frontera que una sola: lo que cambia es
169
+ // que la bóveda las guarda juntas y manda UN aviso de cambio en vez de uno por
170
+ // variable — o sea, el servicio se reinicia una vez, con la configuración entera,
171
+ // en lugar de arrancar a medias mientras quien administra sigue escribiendo.
172
+ // Los nombres viajan DENTRO del sobre, igual que los valores: el proxy tampoco
173
+ // tiene por qué aprender cómo se llaman las variables de un servicio.
174
+ if (data.op === 'var.setMany') {
175
+ // Una bóveda anterior a esto sabe guardar de una en una y nada más. Decirlo es
176
+ // mejor que reventar con un TypeError que no explica qué falta actualizar.
177
+ if (typeof vars.setMany !== 'function') return { ok: false, error: 'admin: this vault cannot save several variables at once (update it)' }
178
+ if (!data.enc || typeof data.enc !== 'object') {
179
+ return { ok: false, error: 'admin: var.setMany needs the variables sealed with the profile content key' }
180
+ }
181
+ const result = await vars.setMany({
182
+ ns: toScope ? data.ns : null,
183
+ pub: toDevice ? data.pub : null,
184
+ enc: data.enc,
185
+ public: typeof data.public === 'boolean' ? data.public : undefined,
186
+ by
187
+ })
188
+ const keys = result?.keys || []
189
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
190
+ await notify('vars', { by, keys, ns: toScope ? data.ns : null })
191
+ return { ok: true, result: result || { ok: true } }
192
+ }
193
+
194
+ if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.set needs a key' }
195
+ if (!data.enc || typeof data.enc !== 'object') {
196
+ // El valor NUNCA viaja en claro: si llega sin sobre, es un error de quien llama,
197
+ // no algo que se pueda «arreglar» aceptándolo.
198
+ return { ok: false, error: 'admin: var.set needs the value sealed with the profile content key' }
199
+ }
200
+ const result = await vars.set({
201
+ ns: toScope ? data.ns : null,
202
+ pub: toDevice ? data.pub : null,
203
+ key: data.key,
204
+ enc: data.enc,
205
+ public: typeof data.public === 'boolean' ? data.public : undefined,
206
+ by
207
+ })
208
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
209
+ // Cambiar la configuración de un servicio a distancia no puede ser invisible: es
210
+ // la contrapartida de delegar (F3 de docs/consola-remota.md).
211
+ await notify('vars', { by, key: data.key, ns: toScope ? data.ns : null })
212
+ return { ok: true, result: result || { ok: true } }
213
+ }
214
+
130
215
  // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
131
216
  // todos los certificados. Las dos cosas o ninguna.
132
217
  //
package/lib/src/config.js CHANGED
@@ -53,6 +53,6 @@ if (process.env.DOTRINO_ENV_WATCH !== '0') {
53
53
  try {
54
54
  await watchEnv({ ns, quiet })
55
55
  } catch (e) {
56
- if (!quiet) console.error('[dotrino-env] sin escucha de cambios (%s): habrá que reiniciar a mano al rotar', e.message)
56
+ if (!quiet) console.error('[dotrino-env] no watch for changes (%s): a rotation will need a manual restart', e.message)
57
57
  }
58
58
  }
package/lib/src/enroll.js CHANGED
@@ -104,13 +104,16 @@ export async function deviceIdOf (pub) {
104
104
  * @param {(...a:any[])=>void} [opts.log]
105
105
  * @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
106
106
  * @param {()=>void} [opts.onPendingChange]
107
+ * @param {(sub:string)=>void} [opts.onDeviceRemoved] se quitó un aparato (fuera del acta y sin papeles):
108
+ * para que quien guarde algo indexado por esa llave lo suelte. Se avisa desde AQUÍ y no desde
109
+ * quien llama porque a `revokeDevice` se entra por dos puertas (el PC y la consola remota).
107
110
  * @param {string[]} [opts.defaultScope]
108
111
  * @param {number} [opts.defaultTtlMs]
109
112
  */
110
113
  export function createEnrollDesk ({
111
114
  identity, iss, proxy, send, sendByPubkey,
112
115
  audit = () => {}, log = () => {},
113
- onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {},
116
+ onChallenge = () => {}, onPendingChange = () => {}, onAdopted = () => {}, onDeviceRemoved = () => {},
114
117
  defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS,
115
118
  // Camino A: lo que ESTA bóveda le manda al aparato para que la meta en su acta. `encPub`
116
119
  // es su llave de CIFRADO — sin ella entra mandando pero sin poder leer el contenido.
@@ -231,7 +234,7 @@ export function createEnrollDesk ({
231
234
  }
232
235
  if (intent !== (pend.mode || 'join')) {
233
236
  audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
234
- return reply(from, { type: MSG_ERROR, error: `este emparejamiento se abrió para «${pend.mode || 'join'}» y el dispositivo pidió «${intent}»` })
237
+ return reply(from, { type: MSG_ERROR, error: `this pairing was opened for "${pend.mode || 'join'}" and the device asked for "${intent}"` })
235
238
  }
236
239
  if (!isFresh(d)) {
237
240
  audit('rejected', { what: 'enroll', reason: 'stale' })
@@ -329,25 +332,25 @@ export function createEnrollDesk ({
329
332
  // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
330
333
  // credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
331
334
  // Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
332
- let acta = null
335
+ let record = null
333
336
  try {
334
337
  if (typeof identity.admitMember === 'function') {
335
338
  const cn = scopeToCn(pend.scope)
336
339
  const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
337
340
  if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
338
341
  }
339
- acta = (await identity.profileActa?.())?.acta || null
340
- } catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
342
+ record = (await identity.profileActa?.())?.acta || null
343
+ } catch (e) { log('[vault] could not admit into the record:', e.message) }
341
344
 
342
345
  audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
343
346
  // Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
344
347
  // con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
345
348
  // El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
346
- reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
349
+ reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta: record })
347
350
  pend.state = 'DONE'
348
351
  pending.delete(pend.token)
349
352
  fire(onPendingChange)
350
- log('[vault] dispositivo aprobado: %s', pend.deviceId)
353
+ log('[vault] device approved: %s', pend.deviceId)
351
354
  return { ok: true, deviceId: pend.deviceId, cert }
352
355
  }
353
356
 
@@ -367,39 +370,39 @@ export function createEnrollDesk ({
367
370
  * pisar una cuenta con datos, y eso no puede pasar por accidente.
368
371
  */
369
372
  async function handleActaSealed (from, p) {
370
- const acta = p?.acta
373
+ const record = p?.acta
371
374
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
372
375
  if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
373
- if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
374
- if (acta.sealer !== iss) {
376
+ if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
377
+ if (record.sealer !== iss) {
375
378
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
376
379
  return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
377
380
  }
378
- if (acta.sealedBy !== pend.dpub) {
381
+ if (record.sealedBy !== pend.dpub) {
379
382
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
380
383
  return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
381
384
  }
382
- if (pend.profileId && acta.profileId !== pend.profileId) {
385
+ if (pend.profileId && record.profileId !== pend.profileId) {
383
386
  audit('rejected', { what: 'adopt', reason: 'other-profile' })
384
387
  return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
385
388
  }
386
389
 
387
390
  try {
388
- const r = await identity.joinProfile(acta)
391
+ const r = await identity.joinProfile(record)
389
392
  if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
390
- audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
393
+ audit('adopt', { device: pend.deviceId, profile: record.profileId, seq: record.seq })
391
394
  // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
392
395
  // dos quedan en la misma versión.
393
- const mia = (await identity.profileActa?.())?.acta || acta
394
- reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mia })
396
+ const mine = (await identity.profileActa?.())?.acta || record
397
+ reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mine })
395
398
  pend.state = 'DONE'
396
399
  pending.delete(pend.token)
397
400
  fire(onPendingChange)
398
- fire(onAdopted, { deviceId: pend.deviceId, profileId: acta.profileId, seq: mia.seq })
399
- log('[vault] cuenta adoptada del dispositivo %s (perfil %s)', pend.deviceId, acta.profileId?.slice(0, 12))
400
- return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
401
+ fire(onAdopted, { deviceId: pend.deviceId, profileId: record.profileId, seq: mine.seq })
402
+ log('[vault] account adopted from device %s (profile %s)', pend.deviceId, record.profileId?.slice(0, 12))
403
+ return { ok: true, adopted: true, profileId: record.profileId, seq: mine.seq }
401
404
  } catch (e) {
402
- log('[vault] no se pudo adoptar la cuenta: %s', e.message)
405
+ log('[vault] could not adopt the account: %s', e.message)
403
406
  reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
404
407
  return { ok: false, error: e.message }
405
408
  }
@@ -435,9 +438,9 @@ export function createEnrollDesk ({
435
438
  async function revoke (nonce) {
436
439
  audit('revoke', { nonce })
437
440
  const { issued } = await identity.listDelegations()
438
- const dele = (issued || []).find((d) => d.nonce === nonce)
441
+ const delegation = (issued || []).find((d) => d.nonce === nonce)
439
442
  const res = await identity.revokeDelegation(nonce)
440
- if (dele?.sub) await emitRevoke(dele.sub, nonce)
443
+ if (delegation?.sub) await emitRevoke(delegation.sub, nonce)
441
444
  return res
442
445
  }
443
446
 
@@ -461,6 +464,7 @@ export function createEnrollDesk ({
461
464
  return done
462
465
  })() }
463
466
  await emitRevoke(sub, mine[0]?.nonce || null)
467
+ fire(onDeviceRemoved, sub)
464
468
  return res
465
469
  }
466
470