@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 +142 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +88 -3
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +26 -22
- 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 +12 -0
- package/lib/src/service.js +218 -92
- package/package.json +10 -5
- package/src/ctl.js +375 -90
- package/src/daemon.js +167 -45
- package/src/manager.js +6 -6
- package/src/profiles.js +27 -4
- package/src/secretsStore.js +191 -25
- package/src/transport.js +2 -2
- package/src/tui/app.js +512 -126
- package/src/tui/i18n.js +118 -22
- package/src/vault.js +351 -44
- package/src/vaultControl.js +253 -64
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> #
|
|
243
|
-
|
|
244
|
-
dotrino-vault secret
|
|
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ó.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 #
|
|
368
|
-
dotrino-vault lock # vuelve a
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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).
|
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,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([
|
|
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]
|
|
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' })
|
|
@@ -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
|
|
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
|
-
|
|
340
|
-
} catch (e) { log('[vault]
|
|
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]
|
|
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
|
|
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 (!
|
|
374
|
-
if (
|
|
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 (
|
|
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 &&
|
|
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(
|
|
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:
|
|
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
|
|
394
|
-
reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta:
|
|
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:
|
|
399
|
-
log('[vault]
|
|
400
|
-
return { ok: true, adopted: true, profileId:
|
|
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]
|
|
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
|
|
441
|
+
const delegation = (issued || []).find((d) => d.nonce === nonce)
|
|
439
442
|
const res = await identity.revokeDelegation(nonce)
|
|
440
|
-
if (
|
|
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
|
|