@dotrino/vault 0.21.0 → 0.23.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
@@ -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`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.21.0",
4
- "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
3
+ "version": "0.23.0",
4
+ "description": "Usa ESTE dispositivo (navegador) como b\u00f3veda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegaci\u00f3n a tus m\u00e1quinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "module": "src/index.js",
@@ -50,5 +50,12 @@
50
50
  "type": "git",
51
51
  "url": "git+https://github.com/imdotrino/dotrino-vault.git",
52
52
  "directory": "lib"
53
+ },
54
+ "devDependencies": {
55
+ "typescript": "^5.7.3",
56
+ "@types/node": "^22.0.0"
57
+ },
58
+ "scripts": {
59
+ "type-check": "tsc --noEmit"
53
60
  }
54
61
  }
package/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,13 @@
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
+ 'vars', 'var.set'
43
+ ])
30
44
 
31
45
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
32
46
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
@@ -40,13 +54,18 @@ export const AUDIT_MAX = 500
40
54
  * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
41
55
  * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
42
56
  * @param {(pub:string)=>Promise<string>} o.deviceIdOf
57
+ * @param {{list:(a:object)=>Promise<any>, set:(a:object)=>Promise<any>}} [o.vars]
58
+ * mostrador de VARIABLES DE ENTORNO. Va inyectado porque aquí no hay cripto ni disco:
59
+ * quien lo implementa (la bóveda) es quien sella con la clave de contenido del perfil y
60
+ * quien decide qué valor puede salir. Sin él, las ops de variables responden que esta
61
+ * bóveda no las atiende, en vez de fingir que se aplicaron.
43
62
  * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
44
63
  * @param {(op:string, info?:object)=>void} [o.audit]
45
64
  * @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
46
65
  * @param {number} [o.ttlMs] vida del cert que se emita.
47
66
  */
48
67
  export function createAdminDesk ({
49
- desk, verify, readActivity = () => [], deviceIdOf,
68
+ desk, verify, readActivity = () => [], deviceIdOf, vars = null,
50
69
  notify = async () => {}, audit = () => {},
51
70
  defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
52
71
  ttlMs, now = () => Date.now()
@@ -127,6 +146,43 @@ export function createAdminDesk ({
127
146
  return { ok: true, result: { ok: true } }
128
147
  }
129
148
 
149
+ // VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
150
+ // qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
151
+ if (data.op === 'vars' || data.op === 'var.set') {
152
+ if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
153
+ // Un destino y solo uno: o un scope, o un aparato. Sin esto, mandar los dos dejaría
154
+ // que quien llama adivine dónde acabó su variable.
155
+ // Booleanos a propósito: comparar los VALORES («proxy» vs una pubkey) nunca da
156
+ // igual, así que mandar los dos destinos se colaba por el hueco.
157
+ const toScope = typeof data.ns === 'string' && !!data.ns
158
+ const toDevice = typeof data.pub === 'string' && !!data.pub
159
+ if (data.op === 'vars') {
160
+ const result = await vars.list({ by })
161
+ audit('admin.vars', { by })
162
+ return { ok: true, result }
163
+ }
164
+ if (toScope === toDevice) return { ok: false, error: 'admin: var.set needs exactly one target (ns or pub)' }
165
+ if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.set needs a key' }
166
+ if (!data.enc || typeof data.enc !== 'object') {
167
+ // El valor NUNCA viaja en claro: si llega sin sobre, es un error de quien llama,
168
+ // no algo que se pueda «arreglar» aceptándolo.
169
+ return { ok: false, error: 'admin: var.set needs the value sealed with the profile content key' }
170
+ }
171
+ const result = await vars.set({
172
+ ns: toScope ? data.ns : null,
173
+ pub: toDevice ? data.pub : null,
174
+ key: data.key,
175
+ enc: data.enc,
176
+ public: typeof data.public === 'boolean' ? data.public : undefined,
177
+ by
178
+ })
179
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
180
+ // Cambiar la configuración de un servicio a distancia no puede ser invisible: es
181
+ // la contrapartida de delegar (F3 de docs/consola-remota.md).
182
+ await notify('vars', { by, key: data.key, ns: toScope ? data.ns : null })
183
+ return { ok: true, result: result || { ok: true } }
184
+ }
185
+
130
186
  // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
131
187
  // todos los certificados. Las dos cosas o ninguna.
132
188
  //
package/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.
@@ -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
 
package/src/types.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare global {
2
+ interface Element { [key: string]: any; }
3
+ interface EventTarget { [key: string]: any; }
4
+ interface HTMLElement { [key: string]: any; }
5
+ interface Event { [key: string]: any; }
6
+ interface Window { [key: string]: any; }
7
+ }