@dotrino/vaultd 0.38.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,7 @@ solo de qué cert se emitió un día.
73
73
 
74
74
  ```sh
75
75
  dotrino-vault members # el acta: qué llaves son tuyas y qué puede hacer cada una
76
- dotrino-vault caps <ID> +firma # cambia permisos (+firma -guarda +lee …)
76
+ dotrino-vault caps <ID> +firma # cambia permisos (+firma -guarda +lee +administra +aprueba …)
77
77
  ```
78
78
 
79
79
  **Los certificados caducan y se renuevan solos.** Un cert dura **30 días**. Mientras
@@ -235,10 +235,14 @@ dotrino-vault approve <código> # aprueba tecleando los 6 dígitos que MUEST
235
235
  dotrino-vault reject <deviceId> # rechaza un dispositivo pendiente
236
236
  dotrino-vault devices # lista dispositivos enrolados / revocados
237
237
  dotrino-vault members # el acta del perfil: qué llaves son tuyas y qué puede cada una
238
- dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma -guarda +lee …)
238
+ dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma -guarda +lee +administra +aprueba …)
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 pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
243
+ dotrino-vault secret policy <ns> approval on|off # el cajón pide tu aprobación en cada lectura (15 min de ventana)
244
+ # Se combina con --service: `--service eco --scope sign` = un bot que firma
245
+ # como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
242
246
  dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
243
247
  # aparatos que sirven ese namespace
244
248
  dotrino-vault secret set <ns> CLAVE=valor CLAVE2=valor2 … # VARIAS de una vez (un solo aviso)
@@ -313,7 +317,7 @@ dos.
313
317
  ```sh
314
318
  dotrino-vault secret set web PUBLIC_URL https://ejemplo.com --public
315
319
  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
320
+ dotrino-vault secret visibility web PUBLIC_URL private # taparla sin tocar el valor (privada → pública no existe)
317
321
  ```
318
322
 
319
323
  - **Se nace privada.** Y **rotar el valor conserva la visibilidad**: exponer un secreto
@@ -752,6 +756,47 @@ CLI de apoyo: `dotrino-env status` (qué hay enrolado aquí), `dotrino-env check
752
756
  los secretos en el entorno de un proceso que no es Node). Primer consumidor:
753
757
  `dotrino-proxy` (TURN de Cloudflare).
754
758
 
759
+ ### Aprobación por uso (el teléfono dice que sí) y la llave SSH en el teléfono
760
+
761
+ Un cajón puede exigir el **visto bueno de un aparato** en cada lectura: para lo que corre
762
+ en tu PC (un asistente, un script) y no debería llevarse tus claves sin que lo sepas.
763
+
764
+ ```sh
765
+ dotrino-vault caps <ID-del-teléfono> +aprueba # quién aprueba (no viaja en un QR)
766
+ dotrino-vault secret policy claude approval on # el cajón «claude» espera el sí
767
+ dotrino-env run --ns claude -- node mi-script.js # el proceso se queda esperando…
768
+ ```
769
+
770
+ …la bóveda apunta el pedido, avisa al teléfono (cola del proxio → aviso nativo), y solo su
771
+ firma entrega las claves — **al proceso que pidió, en memoria**, con una **ventana de
772
+ 15 min** por aparato y cajón para no pedir veinte veces. Lo denegado corta sin reintentos;
773
+ lo que nadie atiende vence a los 5 min; todo queda en `dotrino-vault activity`.
774
+
775
+ **La llave SSH vive en el teléfono.** El daemon expone un **agente SSH** (protocolo de
776
+ `ssh-agent`, socket en `$XDG_RUNTIME_DIR/dotrino-vault/ssh-agent.sock`) que **no guarda
777
+ ninguna llave privada**: la llave nace en el teléfono (vault.dotrino.com → *Llave SSH de
778
+ este aparato*; WebCrypto no extraíble, `ecdsa-sha2-nistp256`) y cada firma es un pedido
779
+ que apruebas ahí. En el PC no queda nada que copiar.
780
+
781
+ ```sh
782
+ dotrino-vault ssh # imprime el export SSH_AUTH_SOCK=… y la receta
783
+ dotrino-vault ssh keys # las llaves registradas (= ssh-add -L)
784
+ ssh mi-servidor # el teléfono pide tu «sí» y firma
785
+ ```
786
+
787
+ **La bóveda puede estar en OTRA máquina** (la de Dotrino en el VPS): en tu PC corre el
788
+ agente **delgado**, que no custodia nada y reenvía cada reto como un pedido a la bóveda
789
+ que enroló ese servicio:
790
+
791
+ ```sh
792
+ dotrino-env enroll --ns claude # una vez, contra la bóveda que sea
793
+ dotrino-env ssh-agent --ns claude # imprime export SSH_AUTH_SOCK=…
794
+ ```
795
+
796
+ Para no aprobar cada comando, reusa la conexión con `ControlMaster auto` +
797
+ `ControlPersist 15m` en `~/.ssh/config`: esa es la ventana de 15 min del SSH.
798
+ `DOTRINO_VAULT_SSH_AGENT=0` apaga el agente.
799
+
755
800
  ## Alcance
756
801
 
757
802
  - **v1 (este):** daemon headless en Node, **multi-perfil**. En **Linux** queda como
package/lib/README.md CHANGED
@@ -279,3 +279,10 @@ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30
279
279
  | bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
280
280
 
281
281
  MIT · parte de [Dotrino](https://dotrino.com).
282
+
283
+ ## Agente SSH delgado (`dotrino-env ssh-agent`)
284
+
285
+ La llave SSH vive en el teléfono del dueño (registrada en su bóveda). En esta máquina solo
286
+ corre un agente `ssh-agent` sin llaves: cada `SIGN_REQUEST` viaja a la bóveda como un
287
+ pedido que el teléfono aprueba firmando. `dotrino-env ssh-agent --ns <ns>` imprime el
288
+ `SSH_AUTH_SOCK`. Como librería: `listSshKeys(args)` y `requestSshSign(args, { keyId, data })`.
package/lib/src/admin.js CHANGED
@@ -41,6 +41,10 @@ export const ADMIN_OPS = Object.freeze([
41
41
  // no debe poder dejar sin configuración a los servicios.
42
42
  // `var.setMany` es la MISMA operación con varias variables dentro de un solo sobre: no
43
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.
44
48
  'vars', 'var.set', 'var.setMany'
45
49
  ])
46
50
 
package/lib/src/atrest.js CHANGED
Binary file
package/lib/src/enroll.js CHANGED
@@ -261,9 +261,15 @@ export function createEnrollDesk ({
261
261
  pend.dpub = d.dpub
262
262
  pend.deviceId = deviceId
263
263
  pend.commit = d.commit
264
- // Llave de CIFRADO del dispositivo: con ella se le envuelve la clave de contenido del
265
- // 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.
266
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
+ }
267
273
  // Certificado de continuidad (opcional): lo firma la identidad que se une, con su
268
274
  // propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
269
275
  if (d.continuity) {
@@ -335,8 +341,11 @@ export function createEnrollDesk ({
335
341
  let record = null
336
342
  try {
337
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.
338
347
  const cn = scopeToCn(pend.scope)
339
- const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
348
+ const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
340
349
  if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
341
350
  }
342
351
  record = (await identity.profileActa?.())?.acta || null
package/lib/src/env.js CHANGED
@@ -98,12 +98,12 @@ let lastApplied = null
98
98
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
99
99
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
100
100
  */
101
- export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
101
+ export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
102
102
  if (override === undefined) override = overrideByDefault()
103
103
  ns = resolveNs(ns)
104
104
  dir = dir || serviceDir(ns)
105
105
  const load = wait ? waitForSecrets : fetchSecrets
106
- const secrets = await load({ dir, ns, onRetry })
106
+ const secrets = await load({ dir, ns, onRetry, onPending })
107
107
 
108
108
  const missing = required.filter((k) => !(k in secrets))
109
109
  if (missing.length) {
@@ -66,6 +66,16 @@ export const MSG = Object.freeze({
66
66
  // Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
67
67
  // aviso de reinicio sin autenticar ES un ataque de denegación.
68
68
  SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
69
+ // REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
70
+ // bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
71
+ // tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
72
+ //
73
+ // El servicio NO se fía de lo que le manden: comprueba la firma de la maestra, comprueba
74
+ // el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
75
+ // destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
76
+ // llave para alguien que la maestra no haya metido en su propio cajón.
77
+ REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, signature }
78
+ REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
69
79
  // --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
70
80
  // Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
71
81
  // Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
@@ -87,7 +97,8 @@ export const SCOPE = Object.freeze({
87
97
  // Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
88
98
  // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
89
99
  // de master y sigue siendo local. No se empareja — se concede desde el PC.
90
- ADMIN: 'vault:admin'
100
+ ADMIN: 'vault:admin',
101
+ APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
91
102
  })
92
103
 
93
104
  /**