@dotrino/vaultd 0.11.0 → 0.13.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.
@@ -36,7 +36,9 @@ const mgr = await runDaemon()
36
36
  // Atajo de dev: --pair imprime el QR directo en stdout (en producción se usa el CLI).
37
37
  // Empareja contra el perfil ACTIVO.
38
38
  if (process.argv.includes('--pair')) {
39
- const { qr, expiresInMs } = mgr.current().startPairing({ label: 'cli' })
39
+ // `startPairing` es asíncrono desde que el QR lleva una CITA del proxio (hay
40
+ // que pedírsela) en vez de la dirección de la conexión.
41
+ const { qr, expiresInMs } = await mgr.current().startPairing({ label: 'cli' })
40
42
  const { url, b64 } = pairUrl(qr)
41
43
  console.log(`\nEmparejá un dispositivo (válido ${expiresInMs / 60000} min):\n`)
42
44
  console.log(qrToString(url))
package/lib/README.md CHANGED
@@ -91,18 +91,128 @@ que los secretos estén. Si el vault no está disponible, **espera** (reintento
91
91
  backoff) — un servicio sin vault no arranca, no opera con secretos viejos ni vacíos.
92
92
  Un fallo NO transitorio (sin enrolar, cert revocado, scope equivocado) sí aborta.
93
93
 
94
+ #### Esperar al vault es la REGLA. La excepción es una sola
95
+
96
+ Un agente enrolado **espera**. No es una preferencia: arrancar igual significaría
97
+ operar con la configuración vieja del `.env`, que es justo lo que el vault vino a
98
+ dejar de ser. Y la espera casi nunca duele, porque estos agentes no son críticos:
99
+ que un bot o un firmador tarden en levantar no rompe a nadie.
100
+
101
+ La **única** excepción conocida es **el proxio**, y no por importancia sino por una
102
+ razón estructural: el vault habla con sus servicios **por el proxio**. Un proxio que
103
+ espera al vault espera a alguien que necesita que el proxio ya esté escuchando —
104
+ abrazo mortal, y con él se cae el vault de todo el mundo. Por eso el proxio arranca
105
+ con lo que tenga y aplica la configuración cuando llega, con `applyEnv`.
106
+
107
+ > `applyEnv` existe **para ese caso**, no como alternativa cómoda al bloqueo. Si tu
108
+ > agente no está en el camino por el que viaja el propio vault, usa
109
+ > `import '@dotrino/vault/config'` y deja que espere. El precio de la excepción es
110
+ > real: lo que sólo se lee al arrancar llega tarde y no toma efecto hasta reiniciar,
111
+ > así que hay que avisarlo en el log — el proxio lo hace.
112
+
94
113
  Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
95
114
 
96
115
  ```bash
97
116
  dotrino-env run --ns miapp -- ./mi-binario
98
117
  ```
99
118
 
119
+ ### Un agente tiene UNA identidad, y se la da el vault
120
+
121
+ Un **aparato** puede llevar varios perfiles, y hasta meter su propia cuenta al vault
122
+ por adopción: llega con una historia que conservar. Un **agente** no es eso. Es un
123
+ servicio: su identidad se la cede el vault y no hay caso en que quiera empujar la
124
+ suya hacia arriba. De ahí tres reglas, que el paquete aplica solas:
125
+
126
+ - **No adopta, nunca.** La invitación declara su modo (`join` / `adopt`); si viene
127
+ abierta para adoptar, `enrollService` la **rechaza al pegarla**, sin salir a la red.
128
+ La intención `join` va además firmada dentro de la petición, para que nadie en el
129
+ medio la convierta en otra cosa.
130
+ - **No acumula.** Enrolar de nuevo **reemplaza** la identidad anterior, que deja de
131
+ existir en ese agente. No es un error a desbloquear con `--force`: es la forma de
132
+ **rotar** la identidad de un agente comprometido. Se avisa por `onReplace` qué se
133
+ descarta.
134
+ - **Un agente, un `ns`.** Varios agentes pueden convivir en una máquina (un
135
+ directorio por namespace); lo que no existe es un agente que sea varios.
136
+
137
+ > ⚠ Si esa llave es además la **identidad de red** del servicio —el caso del proxio,
138
+ > cuyo id de nodo se deriva de ella— reemplazarla le cambia el nombre en la red: las
139
+ > instancias y citas vivas dejan de resolver y los peers que lo tenían pineado lo
140
+ > rechazan hasta re-pinearlo. Es a propósito: así se echa a un nodo comprometido.
141
+
142
+ ### Rotar: la bóveda avisa y el agente SE REINICIA
143
+
144
+ Cambiar un secreto en la bóveda no sirve de nada si quien lo usa no se entera. Al
145
+ guardar, la bóveda manda un **aviso firmado** a los agentes de ese `ns` (sin
146
+ valores: sólo dice que cambió), **agrupando** las escrituras seguidas para que
147
+ cargar cinco valores no provoque cinco reinicios.
148
+
149
+ El agente **no recarga en caliente: termina**, y lo levanta su supervisor (pm2,
150
+ systemd `Restart=always`). Con `import '@dotrino/vault/config'` ya viene puesto.
151
+
152
+ Salir en vez de recargar, por tres razones — y la primera es la de peso:
153
+
154
+ 1. **Borra de memoria el valor viejo.** En JavaScript un secreto no se puede
155
+ borrar: los strings son inmutables, no hay `zeroize`, y el valor sigue en el
156
+ heap hasta que al recolector le apetezca, más lo que capturó cada *closure* y
157
+ cada caché derivada. Una llave se rota casi siempre **porque se filtró**, así
158
+ que dejarla viva en el proceso anula el motivo de rotarla. Un proceso nuevo
159
+ empieza con el heap limpio.
160
+ 2. **Lee todo fresco.** Recargar en caliente exige que cada sitio que leyó una
161
+ variable sepa releerla; esa lista hay que mantenerla para siempre y, cuando se
162
+ queda corta, falla en silencio.
163
+ 3. **Es un interruptor de emergencia.** Revocar el cert de un agente ya no espera a
164
+ que alguien se acuerde de reiniciarlo: recibe el `REVOKED` firmado, se apaga, y
165
+ al arrancar `fetchSecrets` recibe «no autorizado: revoked», que no se arregla
166
+ reintentando. Antes, revocar no le quitaba nada a un proceso ya corriendo.
167
+
168
+ Defensas, porque una señal que provoca reinicios es un arma si se descuida: firma
169
+ de la maestra pineada, `ns` que coincida, frescura y anti-replay, **gracia de
170
+ arranque** y **piso entre avisos** (si la configuración nueva rompe el arranque, sin
171
+ eso el servicio entra en ciclo) y **jitter** (diez agentes del mismo `ns` no salen
172
+ todos en el mismo segundo).
173
+
174
+ ```js
175
+ import { watchEnv } from '@dotrino/vault/env'
176
+ await watchEnv({ ns: 'miapp' }) // termina el proceso al cambiar
177
+ await watchEnv({ ns: 'proxy', onUpdate: (i) => … }) // o decide tú (ver abajo)
178
+ ```
179
+
180
+ `onUpdate` es para cuando terminar **no es una opción**. El caso real es el proxio:
181
+ reiniciarlo corta el transporte de todo el ecosistema, así que anota el aviso, lo
182
+ publica en su `GET /peers` y deja el momento a un humano.
183
+
184
+ ### Precedencia: el vault MANDA
185
+
186
+ Los valores del vault **pisan** los del `.env` y los del entorno. El vault no
187
+ reemplaza al `.env` —que sigue siendo lo que arranca una máquina sin enrolar— pero
188
+ sí tiene la última palabra sobre las claves que administra.
189
+
190
+ Esa es la pieza que hace barata la **rotación**: cambias el valor en un solo lugar y
191
+ ningún `.env` viejo olvidado en un VPS puede seguir ganando. Con la precedencia al
192
+ revés (como estaba hasta la 0.14.0) rotar exigía además ir a limpiar cada copia
193
+ rancia —el trabajo que se quería evitar— y, peor, el servicio arrancaba con la llave
194
+ vieja **sin decir nada**.
195
+
196
+ Lo que sí se dice en voz alta: al arrancar se listan las claves que el vault tuvo que
197
+ pisar. Es la señal de que en esa máquina quedó un `.env` por limpiar.
198
+
199
+ ```bash
200
+ DOTRINO_ENV_OVERRIDE=0 node server.js # escotilla: por esta corrida, gana el entorno
201
+ dotrino-env check # dice qué claves pisaría en esta máquina
202
+ ```
203
+
100
204
  ### API `@dotrino/vault/env`
101
205
 
102
- - `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, skipped }`
103
- (por defecto **no pisa** variables ya presentes en el entorno; `override: true` )
206
+ - `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, overridden, skipped }`
207
+ (por defecto **pisa** lo que ya esté en el entorno; `override: false` invierte la regla).
208
+ `overridden` son las claves que tenían otro valor y el vault reemplazó.
209
+ - `applyEnv(secrets, override?) → { injected, overridden, skipped }` — vuelca un bundle
210
+ ya obtenido, sin pedirlo. Para servicios que **no pueden bloquear su arranque**
211
+ esperando al vault y lo aplican cuando llega (el caso del proxy: el vault le habla
212
+ *por* el proxy, así que esperarlo sería un abrazo mortal).
104
213
  - `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
105
- - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
214
+ - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET` ·
215
+ `DOTRINO_ENV_OVERRIDE`
106
216
  - CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
107
217
 
108
218
  Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
@@ -124,16 +234,37 @@ pineada en el enrolamiento.
124
234
 
125
235
  `startDeviceVault(identity, { proxyUrl? }) → Promise<handle>`
126
236
 
127
- - `startPairing({ scope?, ttlMs?, label? }) → { qr, expiresInMs }`
237
+ - `startPairing({ scope?, ttlMs?, label?, mode?, account? }) → { qr, expiresInMs }`
238
+ - `stopPairing(token)`
128
239
  - `listPending() → [{ deviceId, label }]`
129
240
  - `approve(deviceId, code) → Promise<{ ok, deviceId }>` (code = lo que muestra el dispositivo)
130
241
  - `reject(deviceId)`
131
242
  - `listMachines() → Promise<[{ sub, deviceId, label, scope, exp, nonce }]>`
132
243
  - `revoke(nonce) → Promise`
133
244
  - `getSelfCert() → Promise<cert>` (self-cert `P ← P`, para actuar además de cliente)
134
- - `onPendingChange(fn)`, `close()`
245
+ - `onPendingChange(fn)`, `onAdopted(fn)`, `close()`
135
246
 
136
247
  Cripto y firma: `@dotrino/identity`. Transporte: `@dotrino/proxy-client`. No reimplementa
137
248
  nada del ecosistema.
138
249
 
250
+ ### Qué atiende, y qué NO
251
+
252
+ Esta bóveda **no es** el daemon del PC: comparte el núcleo de enrolamiento
253
+ (`lib/src/enroll.js`, el mismo archivo), pero atiende menos mensajes del protocolo.
254
+
255
+ Atiende: `vault.hello` (la llave que pide el QR corto), `vault.enroll` +
256
+ `vault.acta.sealed` (enrolar y adoptar), `vault.renew` (**renovación automática** del
257
+ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30 días) y
258
+ `vault.devices` (lista + revocados, con re-emisión del `REVOKED` firmado).
259
+
260
+ **No** atiende, y hoy solo existen contra el daemon `dotrino-vault`:
261
+
262
+ | Falta | Qué implica |
263
+ |---|---|
264
+ | `vault.sign` | una máquina no puede pedirle a la maestra que firme por ella |
265
+ | `vault.store` / `vault.get` | no hay store centralizado, ni edición de perfil, ni clave de contenido |
266
+ | `vault.secrets` | `@dotrino/vault/config` (el reemplazo del `.env`) **no funciona** contra un dispositivo |
267
+ | `vault.admin` | sin consola remota |
268
+ | bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
269
+
139
270
  MIT · parte de [Dotrino](https://dotrino.com).
@@ -0,0 +1,146 @@
1
+ /**
2
+ * admin.js — CONSOLA REMOTA: administrar el perfil desde un dispositivo emparejado.
3
+ *
4
+ * Diseño: `dotrino-vault/docs/consola-remota.md`. Módulo PURO (sin `node:*`, sin red,
5
+ * sin disco), igual que `enroll.js`, para que lo puedan usar el daemon del PC y «este
6
+ * dispositivo es bóveda» sin duplicar la regla.
7
+ *
8
+ * QUÉ SE DELEGA Y QUÉ NO — esto es el módulo entero, el resto es plomería:
9
+ *
10
+ * sí · ver el acta y la bitácora · iniciar un emparejamiento (mostrar el QR)
11
+ * · APROBAR o rechazar a quien entra · REVOCAR a un miembro
12
+ * no · cambiar permisos · traspasar el mando · conceder `admin`
13
+ * · nada de los secretos de servicios
14
+ *
15
+ * La frontera no es un capricho: un admin puede **admitir y expulsar**, pero no
16
+ * reescribir quién manda. Así un aparato con `admin` robado hace daño **acotado y
17
+ * reversible** (se le revoca) en vez de poder traspasarse el mando y dejar al dueño
18
+ * fuera de su propia cuenta, que no tiene vuelta atrás. Las operaciones que no se
19
+ * delegan **no existen como mensaje**: no hay nada que autorizar mal.
20
+ *
21
+ * POR QUÉ APROBAR A DISTANCIA NO DEBILITA EL EMPAREJAMIENTO: el código de 6 dígitos es
22
+ * un COMPROMISO (`enroll.js`) — lo genera y lo MUESTRA el aparato que entra, y la bóveda
23
+ * solo firma si el código tecleado lo recompone. Aprobar exige haber leído la pantalla
24
+ * del aparato nuevo, se haga desde el PC o desde el teléfono. Lo que cambia es dónde
25
+ * está el humano, no qué tiene que demostrar.
26
+ */
27
+
28
+ /** 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'])
30
+
31
+ /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
32
+ export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
33
+
34
+ /** Tope de entradas de bitácora por petición. */
35
+ export const AUDIT_MAX = 500
36
+
37
+ /**
38
+ * @param {Object} o
39
+ * @param {Object} o.desk mostrador de emparejamiento (`createEnrollDesk`).
40
+ * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
41
+ * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
42
+ * @param {(pub:string)=>Promise<string>} o.deviceIdOf
43
+ * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
44
+ * @param {(op:string, info?:object)=>void} [o.audit]
45
+ * @param {string[]} [o.defaultScope] lo que recibe un dispositivo emparejado a distancia.
46
+ * @param {number} [o.ttlMs] vida del cert que se emita.
47
+ */
48
+ export function createAdminDesk ({
49
+ desk, verify, readActivity = () => [], deviceIdOf,
50
+ notify = async () => {}, audit = () => {},
51
+ defaultScope = ['vault:sign', 'vault:read', 'vault:store'],
52
+ ttlMs, now = () => Date.now()
53
+ }) {
54
+ const ops = new Set(ADMIN_OPS)
55
+
56
+ // NONCE de un solo uso. `sign`/`get` son idempotentes y les basta la ventana de ±5
57
+ // min; `approve` y `revoke` CAMBIAN ESTADO, así que reproducir uno dentro de esa
58
+ // ventana sí importa (re-aprobar un enrolamiento que el dueño ya rechazó, por
59
+ // ejemplo). Por eso el nonce, y por eso es obligatorio en todas las ops: una lista
60
+ // de excepciones es una invitación a equivocarse.
61
+ const seen = new Map()
62
+ function nonceAlreadyUsed (nonce) {
63
+ const t = now()
64
+ for (const [n, exp] of seen) if (exp <= t) seen.delete(n)
65
+ if (seen.has(nonce)) return true
66
+ seen.set(nonce, t + ADMIN_NONCE_TTL_MS)
67
+ return false
68
+ }
69
+
70
+ /**
71
+ * Atiende una petición ya verificada como *fresca*. Devuelve `{ ok, result }` o
72
+ * `{ ok: false, error }` — quien llama se encarga de responder por el transporte.
73
+ */
74
+ async function handle (data, { signature, cert } = {}) {
75
+ if (!data || !ops.has(data.op)) return { ok: false, error: 'admin: invalid operation' }
76
+ if (typeof data.nonce !== 'string' || data.nonce.length < 16) {
77
+ return { ok: false, error: 'admin: missing single-use nonce' }
78
+ }
79
+
80
+ const chk = await verify({ data, signature, cert })
81
+ if (!chk?.ok) {
82
+ audit('rejected', { what: 'admin', op: data.op, reason: chk?.reason })
83
+ return { ok: false, error: 'unauthorized: ' + (chk?.reason || 'cert') }
84
+ }
85
+ const by = await deviceIdOf(chk.device).catch(() => null)
86
+
87
+ // El nonce se marca DESPUÉS de autorizar: si no, cualquiera podría quemarle los
88
+ // nonces a un admin legítimo mandando basura firmada por nadie.
89
+ if (nonceAlreadyUsed(data.nonce)) {
90
+ audit('rejected', { what: 'admin', op: data.op, by, reason: 'replay' })
91
+ return { ok: false, error: 'admin: nonce already used' }
92
+ }
93
+
94
+ try {
95
+ if (data.op === 'pending') return { ok: true, result: { pending: desk.listPending() } }
96
+
97
+ if (data.op === 'audit') {
98
+ const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
99
+ return { ok: true, result: { entries: readActivity(limit) } }
100
+ }
101
+
102
+ if (data.op === 'pair') {
103
+ // Un admin NO empareja servicios ni crea otros admins. Se corta aquí, en la
104
+ // bóveda, no en la interfaz: una pantalla no es un control de seguridad.
105
+ const scope = Array.isArray(data.scope) && data.scope.length ? data.scope : defaultScope
106
+ const forbidden = scope.find((s) => s === 'vault:admin' || String(s).startsWith('vault:secrets:'))
107
+ if (forbidden) {
108
+ audit('rejected', { what: 'admin', op: 'pair', by, reason: 'forbidden-scope', scope: forbidden })
109
+ return { ok: false, error: 'admin: cannot grant admin or service secrets from here; do that on the vault machine' }
110
+ }
111
+ const label = String(data.label || '').slice(0, 60) || 'remoto'
112
+ const r = await desk.startPairing({ scope, label, ...(ttlMs ? { ttlMs } : {}) })
113
+ audit('admin.pair', { by })
114
+ return { ok: true, result: r }
115
+ }
116
+
117
+ if (data.op === 'approve') {
118
+ const r = await desk.approve(String(data.code || ''), { deviceId: data.deviceId })
119
+ audit('admin.approve', { by, device: data.deviceId || null })
120
+ await notify('enrolled', { deviceId: r?.deviceId || data.deviceId || null, by })
121
+ return { ok: true, result: r || { ok: true } }
122
+ }
123
+
124
+ if (data.op === 'reject') {
125
+ desk.reject(data.deviceId)
126
+ audit('admin.reject', { by, device: data.deviceId || null })
127
+ return { ok: true, result: { ok: true } }
128
+ }
129
+
130
+ if (data.op === 'revoke') {
131
+ const r = await desk.revoke(String(data.certNonce || ''))
132
+ audit('admin.revoke', { by, certNonce: data.certNonce })
133
+ await notify('revoked', { certNonce: data.certNonce, by })
134
+ return { ok: true, result: r || { ok: true } }
135
+ }
136
+ } catch (e) {
137
+ audit('rejected', { what: 'admin', op: data.op, by, reason: e.message })
138
+ return { ok: false, error: e.message }
139
+ }
140
+ return { ok: false, error: 'admin: invalid operation' }
141
+ }
142
+
143
+ return { handle, get nonceCount () { return seen.size } }
144
+ }
145
+
146
+ export default { createAdminDesk, ADMIN_OPS, ADMIN_NONCE_TTL_MS }
Binary file
package/lib/src/config.js CHANGED
@@ -8,19 +8,51 @@
8
8
  * secretos viejos ni vacíos. Un fallo NO transitorio (sin enrolar, cert revocado,
9
9
  * scope equivocado) sí aborta el proceso.
10
10
  *
11
+ * EL VAULT MANDA: lo que venga de aquí PISA lo que ya hubiera en el entorno
12
+ * (incluido un `.env` cargado antes). Ver `env.js` para el porqué.
13
+ *
14
+ * Y queda A LA ESCUCHA: cuando el dueño cambia la configuración en la bóveda, el
15
+ * proceso TERMINA para que su supervisor lo levante limpio. No se recarga en
16
+ * caliente a propósito — salir borra de la memoria el valor viejo, que en
17
+ * JavaScript no hay forma de borrar de otra manera, y una llave se rota casi
18
+ * siempre porque se filtró. Requiere correr bajo pm2 o systemd `Restart=always`.
19
+ * Se apaga con `DOTRINO_ENV_WATCH=0`.
20
+ *
11
21
  * Config por entorno:
12
- * DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
13
- * DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
14
- * DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
22
+ * DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
23
+ * DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
24
+ * DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
25
+ * DOTRINO_ENV_OVERRIDE '0' para NO pisar el entorno en esta corrida (depuración)
26
+ * DOTRINO_ENV_WATCH '0' para no escuchar avisos de cambio (no terminar solo)
15
27
  */
16
- import { loadEnv } from './env.js'
28
+ import { loadEnv, watchEnv } from './env.js'
17
29
 
18
30
  const quiet = process.env.DOTRINO_ENV_QUIET === '1'
19
31
 
20
- const { ns, injected } = await loadEnv({
32
+ const { ns, injected, overridden } = await loadEnv({
21
33
  onRetry: (e, ms) => {
22
34
  if (!quiet) console.error('[dotrino-env] vault no disponible (%s); reintentando en %ds…', e.message, Math.round(ms / 1000))
23
35
  }
24
36
  })
25
37
 
26
- if (!quiet) console.error('[dotrino-env] %d secreto(s) del ns "%s" cargados en process.env', injected.length, ns)
38
+ if (!quiet) {
39
+ console.error('[dotrino-env] %d valor(es) del ns "%s" cargados en process.env', injected.length, ns)
40
+ // Se dice en voz alta: que el vault haya tenido que pisar algo significa que
41
+ // en esta máquina hay un `.env` con valores viejos. Ganó el vault, pero el
42
+ // operador quiere enterarse — es la señal de que quedó basura por limpiar.
43
+ if (overridden.length) {
44
+ console.error('[dotrino-env] pisaron un valor previo del entorno: %s', overridden.join(', '))
45
+ }
46
+ }
47
+
48
+ // La escucha no debe impedir que el proceso termine por su cuenta si no tiene
49
+ // nada más que hacer, así que si falla (agente sin enrolar del todo, proxio
50
+ // caído) se avisa y se sigue: el servicio ya tiene su configuración; lo único
51
+ // que pierde es enterarse del próximo cambio.
52
+ if (process.env.DOTRINO_ENV_WATCH !== '0') {
53
+ try {
54
+ await watchEnv({ ns, quiet })
55
+ } catch (e) {
56
+ if (!quiet) console.error('[dotrino-env] sin escucha de cambios (%s): habrá que reiniciar a mano al rotar', e.message)
57
+ }
58
+ }
package/lib/src/enroll.js CHANGED
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
56
56
  export const scopeToCaps = (scope) =>
57
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
58
 
@@ -119,15 +119,15 @@ export function createEnrollDesk ({
119
119
  // único que necesita el QR corto para que el aparato le hable punto a punto.
120
120
  connToken = null
121
121
  } = {}) {
122
- if (!identity) throw new Error('createEnrollDesk: falta identity')
123
- if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
122
+ if (!identity) throw new Error('createEnrollDesk: missing identity')
123
+ if (!iss) throw new Error('createEnrollDesk: missing iss (master pubkey)')
124
124
 
125
125
  // token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
126
126
  // state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
127
127
  const pending = new Map()
128
128
 
129
129
  const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
130
- const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
130
+ const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
131
131
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
132
132
 
133
133
  /**
@@ -146,14 +146,20 @@ export function createEnrollDesk ({
146
146
  * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
147
147
  * identidad de verdad de la cuenta es `iss`.
148
148
  */
149
- function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
149
+ async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
150
150
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
151
151
  const acct = String(account || '').slice(0, 40)
152
- // INVITACIÓN CORTA: si sabemos nuestra dirección en el proxy, el QR lleva solo
153
- // eso y el nonce de la sesión (13 bytes). La llave, el proxy y el nombre de la
154
- // cuenta los pide el aparato por la red presentando el `sn`. El nonce hace de
155
- // identificador de sesión: no hace falta un token de emparejamiento aparte.
156
- const conn = typeof connToken === 'function' ? connToken() : connToken
152
+ // INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
153
+ // nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
154
+ // aparato por la red presentando el `sn`. El nonce hace de identificador de
155
+ // sesión: no hace falta un token de emparejamiento aparte.
156
+ //
157
+ // `conn` es una CITA del proxio (6 caracteres, un solo uso, caduca en
158
+ // minutos), no la dirección de la conexión: esa pasó a ser una instancia de
159
+ // 24 caracteres, que ni entra cómoda en un QR ni tiene por qué quedar impresa
160
+ // en algo que circula. Por eso se pide una nueva por emparejamiento, y por
161
+ // eso esto es asíncrono.
162
+ const conn = typeof connToken === 'function' ? await connToken() : connToken
157
163
  if (conn) {
158
164
  const sn = randToken(8)
159
165
  pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
@@ -190,7 +196,7 @@ export function createEnrollDesk ({
190
196
  const pend = pending.get(String(p?.sn || ''))
191
197
  if (!pend || Date.now() > pend.exp) {
192
198
  audit('rejected', { what: 'hello', reason: 'sin-sesion' })
193
- return reply(from, { type: MSG_ERROR, error: 'no hay ningún emparejamiento abierto con ese código' })
199
+ return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
194
200
  }
195
201
  // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
196
202
  // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
@@ -209,19 +215,19 @@ export function createEnrollDesk ({
209
215
  async function handleEnroll (from, p) {
210
216
  const d = p?.data
211
217
  if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
212
- return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
218
+ return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
213
219
  }
214
220
  const pend = pending.get(d.token)
215
221
  if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
216
- return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
222
+ return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
217
223
  }
218
- if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
224
+ if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
219
225
  // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
220
226
  // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
221
227
  // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
222
228
  const intent = d.intent || 'join'
223
229
  if (intent !== 'join' && intent !== 'adopt') {
224
- return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
230
+ return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
225
231
  }
226
232
  if (intent !== (pend.mode || 'join')) {
227
233
  audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
@@ -229,22 +235,22 @@ export function createEnrollDesk ({
229
235
  }
230
236
  if (!isFresh(d)) {
231
237
  audit('rejected', { what: 'enroll', reason: 'stale' })
232
- return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
238
+ return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
233
239
  }
234
240
  // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
235
241
  if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
236
242
  audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
237
- return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
243
+ return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
238
244
  }
239
245
  // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
240
246
  // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
241
247
  if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
242
248
  audit('rejected', { what: 'enroll', reason: 'no-commit' })
243
- return reply(from, { type: MSG_ERROR, error: 'este dispositivo usa una versión antigua del emparejamiento (no envía el compromiso del código). Actualízalo y vuelve a intentarlo.' })
249
+ return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
244
250
  }
245
251
  // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
246
252
  if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
247
- return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
253
+ return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
248
254
  }
249
255
 
250
256
  const deviceId = await deviceIdOf(d.dpub)
@@ -283,16 +289,16 @@ export function createEnrollDesk ({
283
289
  */
284
290
  async function approve (code, { deviceId } = {}) {
285
291
  code = String(code || '').trim()
286
- if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
292
+ if (!code) throw new Error('missing code (the digits shown by the device)')
287
293
 
288
294
  let pend
289
295
  if (deviceId) {
290
296
  pend = findPending(deviceId)
291
- if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
297
+ if (!pend) throw new Error('no device awaiting approval with that id')
292
298
  } else {
293
299
  const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
294
- if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
295
- if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
300
+ if (waiting.length === 0) throw new Error('no device awaiting approval')
301
+ if (waiting.length > 1) throw new Error('more than one pairing in flight; restart it with dotrino-vault pair')
296
302
  pend = waiting[0]
297
303
  }
298
304
 
@@ -300,8 +306,8 @@ export function createEnrollDesk ({
300
306
  const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
301
307
  if (expected !== pend.commit) {
302
308
  audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
303
- log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
304
- throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
309
+ log('[vault] wrong code for %s: no certificate was issued', pend.deviceId)
310
+ throw new Error('code does not match the one shown by the device: no certificate was issued. Check it and try again.')
305
311
  }
306
312
 
307
313
  // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
@@ -313,7 +319,7 @@ export function createEnrollDesk ({
313
319
  pend.state = 'AWAITING_ACTA'
314
320
  pend.approvedAt = Date.now()
315
321
  reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
316
- log('[vault] adopción aprobada para %s: esperando el acta sellada', pend.deviceId)
322
+ log('[vault] adoption approved for %s: waiting for the sealed record', pend.deviceId)
317
323
  fire(onPendingChange)
318
324
  return { ok: true, deviceId: pend.deviceId, adopting: true }
319
325
  }
@@ -363,24 +369,24 @@ export function createEnrollDesk ({
363
369
  async function handleActaSealed (from, p) {
364
370
  const acta = p?.acta
365
371
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
366
- if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
367
- if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'acta ausente o ilegible' })
372
+ if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
373
+ if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
368
374
  if (acta.sealer !== iss) {
369
375
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
370
- return reply(from, { type: MSG_ERROR, error: 'esa acta no nombra a esta bóveda como quien manda' })
376
+ return reply(from, { type: MSG_ERROR, error: 'that record does not name this vault as the sealer' })
371
377
  }
372
378
  if (acta.sealedBy !== pend.dpub) {
373
379
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
374
- return reply(from, { type: MSG_ERROR, error: 'esa acta no la selló el dispositivo de este emparejamiento' })
380
+ return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
375
381
  }
376
382
  if (pend.profileId && acta.profileId !== pend.profileId) {
377
383
  audit('rejected', { what: 'adopt', reason: 'other-profile' })
378
- return reply(from, { type: MSG_ERROR, error: 'esa acta es de otra cuenta distinta a la que anunció el dispositivo' })
384
+ return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
379
385
  }
380
386
 
381
387
  try {
382
388
  const r = await identity.joinProfile(acta)
383
- if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
389
+ if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
384
390
  audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
385
391
  // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
386
392
  // dos quedan en la misma versión.
@@ -394,7 +400,7 @@ export function createEnrollDesk ({
394
400
  return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
395
401
  } catch (e) {
396
402
  log('[vault] no se pudo adoptar la cuenta: %s', e.message)
397
- reply(pend.from, { type: MSG_ERROR, error: 'la bóveda no pudo adoptar la cuenta: ' + e.message })
403
+ reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
398
404
  return { ok: false, error: e.message }
399
405
  }
400
406
  }
@@ -405,7 +411,7 @@ export function createEnrollDesk ({
405
411
  ? findPending(deviceId)
406
412
  : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
407
413
  if (!pend) return { ok: false }
408
- reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
414
+ reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
409
415
  pending.delete(pend.token)
410
416
  audit('reject', { device: pend.deviceId })
411
417
  fire(onPendingChange)
@@ -422,7 +428,7 @@ export function createEnrollDesk ({
422
428
  const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
423
429
  const { signature } = await identity.signData(body)
424
430
  try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
425
- catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
431
+ catch (e) { log('[vault] could not emit revoke:', e.message) }
426
432
  }
427
433
 
428
434
  /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */