@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 +145 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +92 -3
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +38 -25
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +22 -0
- package/lib/src/service.js +497 -135
- package/package.json +11 -6
- package/src/ctl.js +639 -98
- package/src/daemon.js +321 -52
- package/src/manager.js +12 -6
- package/src/profiles.js +109 -13
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +881 -29
- package/src/store.js +3 -1
- package/src/transport.js +2 -2
- package/src/tui/app.js +625 -129
- package/src/tui/i18n.js +145 -24
- package/src/vault.js +972 -48
- package/src/vaultControl.js +286 -61
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
|
|
243
|
-
|
|
244
|
-
|
|
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ó.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 #
|
|
368
|
-
dotrino-vault lock # vuelve a
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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).
|
package/bin/dotrino-vaultd.js
CHANGED
|
@@ -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
|
|
61
|
-
const
|
|
62
|
-
console.log =
|
|
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 [
|
|
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 «
|
|
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
|
-
* ·
|
|
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([
|
|
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]
|
|
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: `
|
|
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
|
|
262
|
-
//
|
|
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
|
|
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'] :
|
|
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
|
-
|
|
340
|
-
} catch (e) { log('[vault]
|
|
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]
|
|
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
|
|
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 (!
|
|
374
|
-
if (
|
|
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 (
|
|
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 &&
|
|
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(
|
|
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:
|
|
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
|
|
394
|
-
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta:
|
|
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:
|
|
399
|
-
log('[vault]
|
|
400
|
-
return { ok: true, adopted: true, profileId:
|
|
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]
|
|
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
|
|
450
|
+
const delegation = (issued || []).find((d) => d.nonce === nonce)
|
|
439
451
|
const res = await identity.revokeDelegation(nonce)
|
|
440
|
-
if (
|
|
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
|
|