@dotrino/vaultd 0.26.2 → 0.46.2

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,19 @@ 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 pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
243
+ # Se combina con --service: `--service eco --scope sign` = un bot que firma
244
+ # como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
245
+ dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
246
+ # aparatos que sirven ese namespace
247
+ dotrino-vault secret set <ns> CLAVE=valor CLAVE2=valor2 … # VARIAS de una vez (un solo aviso)
248
+ dotrino-vault secret import <ns> [archivo.env] # lo mismo desde un .env (o por la entrada estándar)
249
+ dotrino-vault secret rm <ns> <CLAVE> # borra una variable del scope
250
+ dotrino-vault secret device set <ID> <CLAVE> <valor> # variable de UN aparato: solo la lee él
251
+ dotrino-vault secret device set <ID> CLAVE=valor … # varias de una vez
252
+ dotrino-vault secret device import <ID> [archivo.env]
253
+ dotrino-vault secret device rm <ID> <CLAVE> # borra una variable de ese aparato
254
+ dotrino-vault secret list # los dos cajones, por nombre (nunca valores)
245
255
  dotrino-vault logs # últimas 40 líneas del servicio (journalctl; solo donde hay systemd)
246
256
  dotrino-vault version # versión instalada (status avisa si el daemon quedó viejo)
247
257
  ```
@@ -253,6 +263,70 @@ su compromiso— y lo aprende cuando lo tecleas. El que sí recibe `deviceId` es
253
263
  El `ns` de un secreto va en minúsculas (`[a-z0-9-]`, hasta 32), la clave en
254
264
  MAYÚSCULAS_CON_GUION_BAJO (hasta 64) y el valor es texto de hasta 8 KB.
255
265
 
266
+ #### Cargar la configuración de un servicio: JUNTA, no de una en una
267
+
268
+ Guardar una variable hace que la bóveda **avise al servicio de que su configuración
269
+ cambió**, y el servicio **sale** para que su supervisor lo levante y la lea entera y
270
+ fresca (`watchEnv`, más abajo). Cargándolas de una en una, seis variables son **seis
271
+ avisos**: el servicio obedece el primero y arranca con lo que hubiera puesto en ese
272
+ momento mientras tú sigues escribiendo el resto — configuración a medias, y encima
273
+ parece que funcionó.
274
+
275
+ Por eso cargar configuración es **una sola orden**:
276
+
277
+ ```sh
278
+ dotrino-vault secret import proxy .env # el .env que ya tienes
279
+ cat .env | dotrino-vault secret import proxy # o por la entrada estándar
280
+ dotrino-vault secret set proxy TURN_KEY_ID=k-123 DB_URL=postgres://… # o a mano, juntas
281
+ ```
282
+
283
+ Se leen `CLAVE=valor` (comentarios con `#`, `export` delante, comillas alrededor del
284
+ valor). **Todo o nada**: si una línea está mal, no se guarda ninguna y se dice cuál —
285
+ media configuración aplicada es peor que ninguna. Un `#` **a mitad de línea no corta el
286
+ valor** (una contraseña puede llevarlo), y una clave repetida es un error, no «gana la
287
+ última».
288
+
289
+ Lo mismo desde la **TUI** (tecla `i` en Variables) y desde la **consola remota**, donde
290
+ se edita todo lo que haga falta y se confirma con **un solo botón**.
291
+
292
+ #### Las variables de entorno se ponen en DOS SITIOS
293
+
294
+ | Dónde | Quién la lee | Para qué |
295
+ |---|---|---|
296
+ | **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 |
297
+ | **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 |
298
+
299
+ Al servicio se le entrega **un solo bundle**: el del scope con el suyo **encima**. Si
300
+ una variable está en los dos, **manda la del aparato** — lo específico gana, igual que
301
+ un `.env` de máquina sobre el general. Así dos servidores sirven el mismo `ns` sin
302
+ tener que partirlo en `proxy-1` y `proxy-2` para cambiar un puerto.
303
+
304
+ Lo del aparato se indexa por su llave, que es la misma que firma la petición: no hay
305
+ forma de pedir lo de otro. Solo se le pueden poner a un **servicio** (un miembro con
306
+ nombre de servicio en el acta): un teléfono no pide bundles, así que guardárselas sería
307
+ configuración muerta. Y **al quitar el aparato se van con él**.
308
+
309
+ #### Pública o privada: si el VALOR puede salir de esta máquina
310
+
311
+ Cada variable, esté en el cajón que esté, es **pública** o **privada**, y eso decide una
312
+ sola cosa: si su valor puede viajar hacia la **consola remota** (`vault.dotrino.com`, un
313
+ aparato tuyo con permiso de administrar). Al servicio que la lee le da igual: recibe las
314
+ dos.
315
+
316
+ ```sh
317
+ dotrino-vault secret set web PUBLIC_URL https://ejemplo.com --public
318
+ dotrino-vault secret set web API_KEY sk-… # sin bandera: privada
319
+ dotrino-vault secret visibility web PUBLIC_URL private # taparla sin tocar el valor (privada → pública no existe)
320
+ ```
321
+
322
+ - **Se nace privada.** Y **rotar el valor conserva la visibilidad**: exponer un secreto
323
+ tiene que ser una decisión, no el efecto colateral de un `set`.
324
+ - Desde la consola remota se ve el **nombre** de todas y el **valor solo de las públicas**;
325
+ a cualquiera se le puede poner un valor nuevo **a ciegas** (rotar una llave que no puedes
326
+ leer es justo para lo que sirve), y **borrar** no se delega. Lo que viaja va **cifrado**
327
+ con la clave de contenido del perfil: el proxy no ve nada. Detalle y límites en
328
+ [`docs/consola-remota.md`](./docs/consola-remota.md).
329
+
256
330
  ### Interfaz de terminal (TUI)
257
331
 
258
332
  Las bóvedas, los dispositivos y los secretos también se manejan desde una **interfaz
@@ -285,13 +359,25 @@ dispositivos/variables que estás viendo:
285
359
  código que muestra el dispositivo, **rechazar** y **revocar**.
286
360
  - **Scopes y variables (secretos):** ver los scopes y sus variables (nunca los
287
361
  valores), **agregar** una variable (con su scope) y **quitar** una variable o un
288
- scope entero.
362
+ scope entero. Son las **compartidas**: las de UN aparato se ponen en la otra
363
+ pestaña, y esta lo dice en vez de repetirlas.
364
+
365
+ Y dentro de **Dispositivos**, con `e` sobre un servicio, sus **variables propias**:
366
+ las que lee solo él y le ganan a las del scope que se llamen igual. Cada cajón se
367
+ administra donde ya elegiste lo que lo distingue — el namespace en su pestaña, el
368
+ aparato en la lista de aparatos. En los dos, `t` cambia si la variable es **pública**
369
+ (su valor se puede ver desde la consola remota) o **privada**.
289
370
 
290
371
  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`.
372
+ recién después muestra el QR, que además dice de qué cuenta salió. Se responde de tres
373
+ formas: entrar a la cuenta activa, estrenar una nueva, o **conectar un servicio**
374
+ (pide el namespace —`proxy`, `geo`…— y emite la invitación con scope
375
+ `vault:secrets:<ns>`, igual que `pair --service`; el QR avisa de que lo que entrega es
376
+ un servicio y no un aparato del dueño). Es lo que hace falta para que ese aparato
377
+ luego tenga variables propias: sin `cn`, la bóveda no se las guarda. Una cuarta
378
+ opción, «adoptar la cuenta que trae el dispositivo», aparece **desactivada**: el
379
+ dispositivo todavía no sabe entregar la suya. Desde la CLI ese camino ya se inicia con
380
+ `dotrino-vault pair --adopt`.
295
381
 
296
382
  `Esc` desde las pestañas vuelve a la lista de bóvedas; `q` sale desde cualquier
297
383
  pantalla, salvo mientras escribes en un campo o respondes una confirmación: ahí se
@@ -354,35 +440,45 @@ Si ya usabas el vault antes de esto, tu identidad de siempre se convierte sola e
354
440
  primer perfil («Perfil 1»): la misma llave, los mismos dispositivos, nada que volver
355
441
  a emparejar.
356
442
 
357
- ### Contraseña del perfil (opcional)
443
+ ### Contraseña del perfil (opcional) — el candado es de ESTA CONSOLA
444
+
445
+ Cada perfil puede llevar contraseña. Con el perfil **bloqueado**, desde la máquina de la
446
+ bóveda no se puede **ver ni tocar nada suyo**: ni sus dispositivos, ni sus variables, ni
447
+ el acta, ni tus datos, ni la bitácora — y tampoco emparejar, aprobar, quitar o guardar
448
+ una variable. La CLI y la TUI contestan «bóveda bloqueada» hasta que alguien teclee la
449
+ contraseña.
358
450
 
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.
451
+ Lo que **no** cambia es el servicio: **tus dispositivos ya emparejados siguen firmando,
452
+ leyendo y guardando** aunque esté bloqueado. Eso viaja por el proxy, no por esta consola,
453
+ y así un reinicio del PC nunca deja tus apps muertas esperando a que alguien teclee algo.
363
454
 
364
455
  ```sh
365
456
  dotrino-vault profile password # pone o cambia la contraseña (te la pregunta)
366
457
  dotrino-vault profile password rm # la quita
367
- dotrino-vault unlock # desbloquea para poder editar
368
- dotrino-vault lock # vuelve a bloquear
458
+ dotrino-vault unlock # abre la bóveda en esta consola
459
+ dotrino-vault lock # vuelve a cerrarla
369
460
  ```
370
461
 
462
+ Lo único que se sigue viendo con el candado puesto es que **existe** y que está cerrada
463
+ (`status`, `profile ls`): si no, no habría forma de saber qué abrir.
464
+
371
465
  El perfil se vuelve a bloquear al reiniciar el servicio. La contraseña **no se
372
466
  guarda**: solo un verificador con sal (PBKDF2), igual que el candado del navegador.
373
467
  Tiene un mínimo de 4 caracteres y, tras 5 intentos fallidos, cada intento nuevo
374
468
  espera cada vez más (hasta 5 minutos); la cuenta de fallos se guarda, así que
375
469
  reiniciar no la borra.
376
470
 
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.)
471
+ Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha: cualquier
472
+ comando que mire o toque esa bóveda falla con «perfil bloqueado» y hay que correr
473
+ `dotrino-vault unlock` antes. La TUI sí la pide sola: al **entrar** a una bóveda cerrada
474
+ (y antes de enseñar nada) te pregunta la contraseña.
381
475
 
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).
476
+ Para que quede claro qué protege y qué no. **Protege la consola**: que otro que se siente
477
+ en tu máquina vea o toque lo que hay en esa bóveda. **No** cifra la llave en el disco —de
478
+ eso se encarga el cifrado en reposo, más abajo, que hoy no usa la contraseña—, así que
479
+ alguien con acceso a esta máquina como tu usuario o como root sigue pudiendo leer los
480
+ archivos por su cuenta. Es un candado de la puerta por la que se administra, no una
481
+ imposibilidad criptográfica.
386
482
 
387
483
  ## Emparejar un aparato
388
484
 
@@ -463,8 +559,8 @@ La excepción, dicha sin adornos: la sincronización del **perfil**
463
559
  (`profileSet`/`profileGet`) todavía viaja **sin cifrar**, y una operación que llegue
464
560
  en claro se guarda en claro. Es deuda, no diseño.
465
561
 
466
- El candado de contraseña solo bloquea **editar el perfil**: guardar y leer contenido
467
- siguen funcionando con el perfil bloqueado.
562
+ El candado de contraseña cierra **esta consola** (ver arriba): guardar y leer contenido
563
+ desde tus dispositivos siguen funcionando con el perfil bloqueado.
468
564
 
469
565
  (El otro store, el «árbol de contenidos» de `vault.json`, es hoy un esqueleto: se
470
566
  puede leer con `vault.get`, pero ningún mensaje del protocolo escribe nodos.)
@@ -601,6 +697,30 @@ secreto no se puede borrar de la memoria (los strings son inmutables, no hay
601
697
  `zeroize`) y una llave se rota casi siempre *porque se filtró*: un proceso nuevo
602
698
  empieza con el heap limpio.
603
699
 
700
+ **Y no se fía del aviso: al conectar, COMPARA.** Un aviso es un mensaje, y los mensajes
701
+ se pierden. Si el agente está vivo pero incomunicado —se cayó el proxio, se fue la red—
702
+ el aviso se le encola; si el corte pasa de **5 minutos** lo descarta al llegar (ventana
703
+ de frescura) y si pasa de **24 h** ni llega (caduca en la cola). Antes ahí se acababa:
704
+ al reconectar volvía a *escuchar*, nunca preguntaba, y se quedaba con la configuración
705
+ vieja para siempre mientras el log decía «ignorado» como si estuviera bien. Ahora, en
706
+ **cada conexión**, el agente pide su bundle y compara la huella con la que tiene
707
+ aplicada; si no coincide, reacciona igual que con el aviso. El aviso sigue siendo el
708
+ camino rápido —de segundos—; la comparación es el que no se pierde. Cubre también los
709
+ avisos que el propio agente descarta por la gracia de arranque o el piso entre avisos, y
710
+ la **revocación** que ocurrió mientras estaba incomunicado (la comparación recibe *no
711
+ autorizado: revoked* y lo apaga). Comparar tiene su propio piso —`reconcileMinMs`, 30 s—
712
+ para que una conexión que va y viene no le pida el bundle a la bóveda cada cinco
713
+ segundos.
714
+
715
+ **Y no puede volverse un ciclo de reinicios.** Lo que se compara son **dos bundles de la
716
+ bóveda**, nunca el `.env` contra el bundle: la referencia es lo que el agente recibió, así
717
+ que *recibir la configuración por primera vez* —tarde, que es como la recibe el proxio—
718
+ no es un cambio. Encima, un reinicio por comparación no puede repetirse más de una vez
719
+ por **gracia de arranque**: dentro de esos 30 s la comparación **se aplaza** (a
720
+ diferencia del aviso, que ahí sí se descarta), de modo que ni un fallo sistemático
721
+ lograría más de un reinicio cada 30 s — tiempo de sobra para que el supervisor lo marque
722
+ como inestable en vez de que la máquina se pase el día arrancando.
723
+
604
724
  **Modos de fallo:**
605
725
 
606
726
  - **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,19 @@
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
+ // Ver un valor, su histórico o volver a una versión NO están, y es a propósito: un
45
+ // aparato que administra no tiene sobres de lo privado (solo el servicio dueño y la
46
+ // recuperación). Eso lo hace la bóveda en su máquina (`secret show/history/revert`).
47
+ // No lo cablees.
48
+ 'vars', 'var.set', 'var.setMany'
49
+ ])
30
50
 
31
51
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
32
52
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
@@ -40,13 +60,18 @@ export const AUDIT_MAX = 500
40
60
  * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
41
61
  * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
42
62
  * @param {(pub:string)=>Promise<string>} o.deviceIdOf
63
+ * @param {{list:(a:object)=>Promise<any>, set:(a:object)=>Promise<any>, setMany?:(a:object)=>Promise<any>}} [o.vars]
64
+ * mostrador de VARIABLES DE ENTORNO. Va inyectado porque aquí no hay cripto ni disco:
65
+ * quien lo implementa (la bóveda) es quien sella con la clave de contenido del perfil y
66
+ * quien decide qué valor puede salir. Sin él, las ops de variables responden que esta
67
+ * bóveda no las atiende, en vez de fingir que se aplicaron.
43
68
  * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
44
69
  * @param {(op:string, info?:object)=>void} [o.audit]
45
70
  * @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
46
71
  * @param {number} [o.ttlMs] vida del cert que se emita.
47
72
  */
48
73
  export function createAdminDesk ({
49
- desk, verify, readActivity = () => [], deviceIdOf,
74
+ desk, verify, readActivity = () => [], deviceIdOf, vars = null,
50
75
  notify = async () => {}, audit = () => {},
51
76
  defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
52
77
  ttlMs, now = () => Date.now()
@@ -127,6 +152,70 @@ export function createAdminDesk ({
127
152
  return { ok: true, result: { ok: true } }
128
153
  }
129
154
 
155
+ // VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
156
+ // qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
157
+ if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany') {
158
+ if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
159
+ // Un destino y solo uno: o un scope, o un aparato. Sin esto, mandar los dos dejaría
160
+ // que quien llama adivine dónde acabó su variable.
161
+ // Booleanos a propósito: comparar los VALORES («proxy» vs una pubkey) nunca da
162
+ // igual, así que mandar los dos destinos se colaba por el hueco.
163
+ const toScope = typeof data.ns === 'string' && !!data.ns
164
+ const toDevice = typeof data.pub === 'string' && !!data.pub
165
+ if (data.op === 'vars') {
166
+ const result = await vars.list({ by })
167
+ audit('admin.vars', { by })
168
+ return { ok: true, result }
169
+ }
170
+ if (toScope === toDevice) return { ok: false, error: 'admin: var.set needs exactly one target (ns or pub)' }
171
+
172
+ // VARIAS DE UNA VEZ. Mismo permiso y misma frontera que una sola: lo que cambia es
173
+ // que la bóveda las guarda juntas y manda UN aviso de cambio en vez de uno por
174
+ // variable — o sea, el servicio se reinicia una vez, con la configuración entera,
175
+ // en lugar de arrancar a medias mientras quien administra sigue escribiendo.
176
+ // Los nombres viajan DENTRO del sobre, igual que los valores: el proxy tampoco
177
+ // tiene por qué aprender cómo se llaman las variables de un servicio.
178
+ if (data.op === 'var.setMany') {
179
+ // Una bóveda anterior a esto sabe guardar de una en una y nada más. Decirlo es
180
+ // mejor que reventar con un TypeError que no explica qué falta actualizar.
181
+ if (typeof vars.setMany !== 'function') return { ok: false, error: 'admin: this vault cannot save several variables at once (update it)' }
182
+ if (!data.enc || typeof data.enc !== 'object') {
183
+ return { ok: false, error: 'admin: var.setMany needs the variables sealed with the profile content key' }
184
+ }
185
+ const result = await vars.setMany({
186
+ ns: toScope ? data.ns : null,
187
+ pub: toDevice ? data.pub : null,
188
+ enc: data.enc,
189
+ public: typeof data.public === 'boolean' ? data.public : undefined,
190
+ by
191
+ })
192
+ const keys = result?.keys || []
193
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
194
+ await notify('vars', { by, keys, ns: toScope ? data.ns : null })
195
+ return { ok: true, result: result || { ok: true } }
196
+ }
197
+
198
+ if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.set needs a key' }
199
+ if (!data.enc || typeof data.enc !== 'object') {
200
+ // El valor NUNCA viaja en claro: si llega sin sobre, es un error de quien llama,
201
+ // no algo que se pueda «arreglar» aceptándolo.
202
+ return { ok: false, error: 'admin: var.set needs the value sealed with the profile content key' }
203
+ }
204
+ const result = await vars.set({
205
+ ns: toScope ? data.ns : null,
206
+ pub: toDevice ? data.pub : null,
207
+ key: data.key,
208
+ enc: data.enc,
209
+ public: typeof data.public === 'boolean' ? data.public : undefined,
210
+ by
211
+ })
212
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
213
+ // Cambiar la configuración de un servicio a distancia no puede ser invisible: es
214
+ // la contrapartida de delegar (F3 de docs/consola-remota.md).
215
+ await notify('vars', { by, key: data.key, ns: toScope ? data.ns : null })
216
+ return { ok: true, result: result || { ok: true } }
217
+ }
218
+
130
219
  // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
131
220
  // todos los certificados. Las dos cosas o ninguna.
132
221
  //
package/lib/src/atrest.js CHANGED
Binary file
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' })
@@ -258,9 +261,15 @@ export function createEnrollDesk ({
258
261
  pend.dpub = d.dpub
259
262
  pend.deviceId = deviceId
260
263
  pend.commit = d.commit
261
- // Llave de CIFRADO del dispositivo: con ella se le envuelve la clave de contenido del
262
- // perfil al admitirlo. Sin ella entra, pero no podrá leer lo que haya guardado.
264
+ // Llave de CIFRADO del dispositivo: con ella se le envuelve la clave del cajón al
265
+ // admitirlo. Un SERVICIO sin ella entraría al acta y no podría leer NUNCA ninguna
266
+ // variable —las privadas van selladas a esta llave—, así que se corta aquí en vez
267
+ // de admitirlo y dejar que falle más tarde y en otro sitio. Un dispositivo de
268
+ // persona sí puede entrar sin ella: no lee variables de servicio.
263
269
  if (typeof d.encPub === 'string') pend.encPub = d.encPub
270
+ if (!pend.encPub && scopeToCn(pend.scope)) {
271
+ return reply(from, { type: MSG_ERROR, error: 'a service must send its encryption key (update @dotrino/vault on the service)' })
272
+ }
264
273
  // Certificado de continuidad (opcional): lo firma la identidad que se une, con su
265
274
  // propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
266
275
  if (d.continuity) {
@@ -329,25 +338,28 @@ export function createEnrollDesk ({
329
338
  // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
330
339
  // credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
331
340
  // Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
332
- let acta = null
341
+ let record = null
333
342
  try {
334
343
  if (typeof identity.admitMember === 'function') {
344
+ // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
345
+ // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
346
+ // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
335
347
  const cn = scopeToCn(pend.scope)
336
- const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
348
+ const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
337
349
  if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
338
350
  }
339
- acta = (await identity.profileActa?.())?.acta || null
340
- } catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
351
+ record = (await identity.profileActa?.())?.acta || null
352
+ } catch (e) { log('[vault] could not admit into the record:', e.message) }
341
353
 
342
354
  audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
343
355
  // Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
344
356
  // con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
345
357
  // 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 })
358
+ reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta: record })
347
359
  pend.state = 'DONE'
348
360
  pending.delete(pend.token)
349
361
  fire(onPendingChange)
350
- log('[vault] dispositivo aprobado: %s', pend.deviceId)
362
+ log('[vault] device approved: %s', pend.deviceId)
351
363
  return { ok: true, deviceId: pend.deviceId, cert }
352
364
  }
353
365
 
@@ -367,39 +379,39 @@ export function createEnrollDesk ({
367
379
  * pisar una cuenta con datos, y eso no puede pasar por accidente.
368
380
  */
369
381
  async function handleActaSealed (from, p) {
370
- const acta = p?.acta
382
+ const record = p?.acta
371
383
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
372
384
  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) {
385
+ if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
386
+ if (record.sealer !== iss) {
375
387
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
376
388
  return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
377
389
  }
378
- if (acta.sealedBy !== pend.dpub) {
390
+ if (record.sealedBy !== pend.dpub) {
379
391
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
380
392
  return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
381
393
  }
382
- if (pend.profileId && acta.profileId !== pend.profileId) {
394
+ if (pend.profileId && record.profileId !== pend.profileId) {
383
395
  audit('rejected', { what: 'adopt', reason: 'other-profile' })
384
396
  return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
385
397
  }
386
398
 
387
399
  try {
388
- const r = await identity.joinProfile(acta)
400
+ const r = await identity.joinProfile(record)
389
401
  if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
390
- audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
402
+ audit('adopt', { device: pend.deviceId, profile: record.profileId, seq: record.seq })
391
403
  // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
392
404
  // 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 })
405
+ const mine = (await identity.profileActa?.())?.acta || record
406
+ reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mine })
395
407
  pend.state = 'DONE'
396
408
  pending.delete(pend.token)
397
409
  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 }
410
+ fire(onAdopted, { deviceId: pend.deviceId, profileId: record.profileId, seq: mine.seq })
411
+ log('[vault] account adopted from device %s (profile %s)', pend.deviceId, record.profileId?.slice(0, 12))
412
+ return { ok: true, adopted: true, profileId: record.profileId, seq: mine.seq }
401
413
  } catch (e) {
402
- log('[vault] no se pudo adoptar la cuenta: %s', e.message)
414
+ log('[vault] could not adopt the account: %s', e.message)
403
415
  reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
404
416
  return { ok: false, error: e.message }
405
417
  }
@@ -435,9 +447,9 @@ export function createEnrollDesk ({
435
447
  async function revoke (nonce) {
436
448
  audit('revoke', { nonce })
437
449
  const { issued } = await identity.listDelegations()
438
- const dele = (issued || []).find((d) => d.nonce === nonce)
450
+ const delegation = (issued || []).find((d) => d.nonce === nonce)
439
451
  const res = await identity.revokeDelegation(nonce)
440
- if (dele?.sub) await emitRevoke(dele.sub, nonce)
452
+ if (delegation?.sub) await emitRevoke(delegation.sub, nonce)
441
453
  return res
442
454
  }
443
455
 
@@ -461,6 +473,7 @@ export function createEnrollDesk ({
461
473
  return done
462
474
  })() }
463
475
  await emitRevoke(sub, mine[0]?.nonce || null)
476
+ fire(onDeviceRemoved, sub)
464
477
  return res
465
478
  }
466
479