@dotrino/vaultd 0.12.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.
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
  /**
@@ -196,7 +196,7 @@ export function createEnrollDesk ({
196
196
  const pend = pending.get(String(p?.sn || ''))
197
197
  if (!pend || Date.now() > pend.exp) {
198
198
  audit('rejected', { what: 'hello', reason: 'sin-sesion' })
199
- 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' })
200
200
  }
201
201
  // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
202
202
  // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
@@ -215,19 +215,19 @@ export function createEnrollDesk ({
215
215
  async function handleEnroll (from, p) {
216
216
  const d = p?.data
217
217
  if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
218
- return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
218
+ return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
219
219
  }
220
220
  const pend = pending.get(d.token)
221
221
  if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
222
- 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' })
223
223
  }
224
- 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' })
225
225
  // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
226
226
  // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
227
227
  // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
228
228
  const intent = d.intent || 'join'
229
229
  if (intent !== 'join' && intent !== 'adopt') {
230
- return reply(from, { type: MSG_ERROR, error: 'intención desconocida: ' + intent })
230
+ return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
231
231
  }
232
232
  if (intent !== (pend.mode || 'join')) {
233
233
  audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
@@ -235,22 +235,22 @@ export function createEnrollDesk ({
235
235
  }
236
236
  if (!isFresh(d)) {
237
237
  audit('rejected', { what: 'enroll', reason: 'stale' })
238
- 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)' })
239
239
  }
240
240
  // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
241
241
  if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
242
242
  audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
243
- return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
243
+ return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
244
244
  }
245
245
  // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
246
246
  // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
247
247
  if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
248
248
  audit('rejected', { what: 'enroll', reason: 'no-commit' })
249
- 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.' })
250
250
  }
251
251
  // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
252
252
  if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
253
- 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' })
254
254
  }
255
255
 
256
256
  const deviceId = await deviceIdOf(d.dpub)
@@ -289,16 +289,16 @@ export function createEnrollDesk ({
289
289
  */
290
290
  async function approve (code, { deviceId } = {}) {
291
291
  code = String(code || '').trim()
292
- 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)')
293
293
 
294
294
  let pend
295
295
  if (deviceId) {
296
296
  pend = findPending(deviceId)
297
- 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')
298
298
  } else {
299
299
  const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
300
- if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
301
- 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')
302
302
  pend = waiting[0]
303
303
  }
304
304
 
@@ -306,8 +306,8 @@ export function createEnrollDesk ({
306
306
  const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
307
307
  if (expected !== pend.commit) {
308
308
  audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
309
- log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
310
- 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.')
311
311
  }
312
312
 
313
313
  // CAMINO A · aquí la bóveda no entrega un cert: entrega SU IDENTIDAD para que el
@@ -319,7 +319,7 @@ export function createEnrollDesk ({
319
319
  pend.state = 'AWAITING_ACTA'
320
320
  pend.approvedAt = Date.now()
321
321
  reply(pend.from, { type: MSG_ENROLL_ADOPT, code, pub: iss, encPub: encPub || null, label: vaultLabel || '' })
322
- 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)
323
323
  fire(onPendingChange)
324
324
  return { ok: true, deviceId: pend.deviceId, adopting: true }
325
325
  }
@@ -369,24 +369,24 @@ export function createEnrollDesk ({
369
369
  async function handleActaSealed (from, p) {
370
370
  const acta = p?.acta
371
371
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
372
- if (!pend) return reply(from, { type: MSG_ERROR, error: 'no hay ninguna adopción esperando un acta' })
373
- 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' })
374
374
  if (acta.sealer !== iss) {
375
375
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
376
- 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' })
377
377
  }
378
378
  if (acta.sealedBy !== pend.dpub) {
379
379
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
380
- 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' })
381
381
  }
382
382
  if (pend.profileId && acta.profileId !== pend.profileId) {
383
383
  audit('rejected', { what: 'adopt', reason: 'other-profile' })
384
- 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' })
385
385
  }
386
386
 
387
387
  try {
388
388
  const r = await identity.joinProfile(acta)
389
- if (!r?.joined) throw new Error(r?.reason || 'no se pudo adoptar')
389
+ if (!r?.joined) throw new Error(r?.reason || 'could not adopt')
390
390
  audit('adopt', { device: pend.deviceId, profile: acta.profileId, seq: acta.seq })
391
391
  // El acta que vuelve es la que la bóveda tiene guardada: el aparato la adopta y los
392
392
  // dos quedan en la misma versión.
@@ -400,7 +400,7 @@ export function createEnrollDesk ({
400
400
  return { ok: true, adopted: true, profileId: acta.profileId, seq: mia.seq }
401
401
  } catch (e) {
402
402
  log('[vault] no se pudo adoptar la cuenta: %s', e.message)
403
- 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 })
404
404
  return { ok: false, error: e.message }
405
405
  }
406
406
  }
@@ -411,7 +411,7 @@ export function createEnrollDesk ({
411
411
  ? findPending(deviceId)
412
412
  : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
413
413
  if (!pend) return { ok: false }
414
- reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
414
+ reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
415
415
  pending.delete(pend.token)
416
416
  audit('reject', { device: pend.deviceId })
417
417
  fire(onPendingChange)
@@ -428,7 +428,7 @@ export function createEnrollDesk ({
428
428
  const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
429
429
  const { signature } = await identity.signData(body)
430
430
  try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
431
- catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
431
+ catch (e) { log('[vault] could not emit revoke:', e.message) }
432
432
  }
433
433
 
434
434
  /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */