@dotrino/vault 0.14.0 → 0.15.1

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
@@ -91,12 +91,54 @@ 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
+
100
142
  ### Precedencia: el vault MANDA
101
143
 
102
144
  Los valores del vault **pisan** los del `.env` y los del entorno. El vault no
@@ -26,7 +26,6 @@ import { parseInvite as sharedParseInvite } from '../src/invite.js'
26
26
 
27
27
  const argv = process.argv.slice(2)
28
28
  const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
29
- const has = (name) => argv.includes('--' + name)
30
29
 
31
30
  function help () {
32
31
  console.log(`dotrino-env — credenciales del vault en vez del .env
@@ -75,9 +74,6 @@ async function cmdEnroll () {
75
74
  const ns = flag('ns')
76
75
  if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
77
76
  const dir = flag('dir') || serviceDir(ns)
78
- if (readServiceIdentity(dir) && !has('force')) {
79
- console.error('ya hay un servicio enrolado en %s (usa --force para re-enrolar)', dir); process.exit(2)
80
- }
81
77
  const qr = parseInvite(flag('qr') || await readInvite())
82
78
 
83
79
  console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
@@ -86,6 +82,17 @@ async function cmdEnroll () {
86
82
  ns,
87
83
  dir,
88
84
  label: flag('label') || 'servicio:' + ns,
85
+ // Un agente tiene UNA identidad y se la da el vault: re-enrolar REEMPLAZA,
86
+ // no acumula. Antes había una reja (`--force`) que hacía de esto un error a
87
+ // desbloquear; sobra, porque no existe la alternativa de "quedarse con las
88
+ // dos". Lo que sí corresponde es que se vea qué se está tirando.
89
+ onReplace: (prev) => {
90
+ console.log('\n ⚠ Este agente YA tenía identidad (dispositivo %s, del %s).',
91
+ prev.deviceId, new Date(prev.enrolledAt).toISOString().slice(0, 10))
92
+ console.log(' Se DESCARTA: el vault le cede una nueva y la anterior deja de existir aquí.')
93
+ console.log(' Si esa llave era además la identidad de red del servicio (el caso del')
94
+ console.log(' proxio), su id de nodo cambia y sus peers lo rechazan hasta re-pinearlo.\n')
95
+ },
89
96
  onCode: ({ deviceId, code }) => {
90
97
  console.log('\n Dispositivo: %s', deviceId)
91
98
  console.log(' APRUEBA en el vault tipeando este código:\n')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
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'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/env.js CHANGED
@@ -113,10 +113,19 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
113
113
  /**
114
114
  * Vuelca un bundle de secretos en `process.env` y cuenta qué cambió.
115
115
  *
116
- * Separado de `loadEnv` porque un servicio que NO puede bloquear su arranque
117
- * esperando al vault (el proxy: el vault le habla POR el proxy, así que
118
- * esperarlo sería un abrazo mortal) igual necesita aplicar el bundle cuando
119
- * llegue, tarde y por su cuenta.
116
+ * ESTO NO ES LA FORMA NORMAL. Un agente enrolado ESPERA al vault
117
+ * (`loadEnv` / `import '@dotrino/vault/config'`): arrancar igual sería operar con
118
+ * la configuración vieja del `.env`, que es justo lo que el vault vino a dejar de
119
+ * ser, y la espera casi nunca duele porque estos agentes no son críticos.
120
+ *
121
+ * `applyEnv` existe para la ÚNICA excepción estructural: **el proxio**. El vault
122
+ * habla con sus servicios POR el proxio, así que un proxio que espera al vault
123
+ * espera a alguien que necesita el proxio escuchando — abrazo mortal, y con él se
124
+ * cae el vault de todos. Ese caso arranca con lo que tenga y aplica el bundle
125
+ * cuando llega, tarde y por su cuenta.
126
+ *
127
+ * Si tu agente no está en el camino por el que viaja el propio vault, no uses
128
+ * esto: usa `loadEnv` y deja que espere.
120
129
  *
121
130
  * @returns {{injected:string[], overridden:string[], skipped:string[]}}
122
131
  */
package/src/service.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from '@dotrino/identity/capabilities'
27
27
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
28
28
  import { makeEphemeralKey, openSealed } from './sealed.js'
29
+ import { parseInvite } from './invite.js'
29
30
 
30
31
  const IDENTITY_FILE = 'service-identity.json'
31
32
  const FRESH_WINDOW_MS = 5 * 60 * 1000
@@ -74,9 +75,36 @@ async function verificarHola (p, sn) {
74
75
  if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
75
76
  throw new Error('la respuesta de la bóveda no está bien firmada')
76
77
  }
78
+ // El modo también viene aquí, y aquí viene FIRMADO por la bóveda. Se comprueba
79
+ // de nuevo aunque ya se haya mirado el del QR: en la forma corta el QR es un
80
+ // código que pasó por manos ajenas, y esta es la primera vez que la bóveda
81
+ // dice de su puño y letra qué se propone hacer.
82
+ rechazarAdopcion(b.m)
77
83
  return b
78
84
  }
79
85
 
86
+ /**
87
+ * UN AGENTE NUNCA TRANSFIERE SU IDENTIDAD: el vault propone, el agente acepta.
88
+ *
89
+ * El emparejamiento tiene dos modos y los declara la bóveda en la invitación:
90
+ * `join` (el que se enrola entra a la cuenta de la bóveda) y `adopt` (la bóveda
91
+ * se queda con la cuenta que trae el aparato). El segundo existe para APARATOS,
92
+ * que llegan con una cuenta propia y una historia que conservar.
93
+ *
94
+ * Un agente no tiene nada de eso: es un servicio, su identidad se la da el vault
95
+ * y no hay caso en que quiera empujar la suya hacia arriba. Así que este camino
96
+ * no se negocia, se rechaza — y se rechaza ACÁ, cuando el humano pega la
97
+ * invitación, en vez de dejar que el viaje termine en un «intent-mismatch» del
98
+ * otro lado que no le explica nada a nadie.
99
+ */
100
+ function rechazarAdopcion (modo) {
101
+ if (modo !== 'adopt') return
102
+ throw new Error(
103
+ 'esta invitación se abrió para ADOPTAR la cuenta del aparato, y un agente no transfiere su identidad: ' +
104
+ 'la suya se la cede el vault. Abre el emparejamiento sin `--adopt` (`dotrino-vault pair --service <ns>`).'
105
+ )
106
+ }
107
+
80
108
  /**
81
109
  * Canjea la cita del QR y devuelve la instancia a la que apunta.
82
110
  *
@@ -153,26 +181,68 @@ function writeServiceIdentity (dir, obj) {
153
181
  }
154
182
 
155
183
  /**
156
- * Enrola ESTE servicio contra el vault (una sola vez; persiste la identidad).
157
- * En el vault se corre antes `dotrino-vault pair --service <ns>`; el QR/payload
158
- * de ese comando es el `qr` de aquí. Muestra un código por `onCode` (o stdout):
159
- * el dueño lo tipea en el vault (`dotrino-vault approve <código>`).
184
+ * Enrola ESTE servicio contra el vault y persiste su identidad.
185
+ *
186
+ * UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
187
+ * aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
188
+ * adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
189
+ * el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
190
+ * había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
191
+ * es un servicio, no una persona; no tiene por qué "ser varios".
192
+ *
193
+ * Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
194
+ * es además la forma de rotar la identidad de un agente comprometido. Lo que sí
195
+ * hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
196
+ *
197
+ * En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
198
+ * que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
199
+ * Muestra un código por `onCode`: el dueño lo tipea en el vault
200
+ * (`dotrino-vault approve <código>`).
160
201
  *
161
202
  * @param {Object} opts
162
- * @param {{v:number, iss:string, proxy:string, token:string, sn:string}|string} opts.qr QR v2 (objeto o JSON string).
203
+ * @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
163
204
  * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
164
205
  * @param {string} opts.dir Dónde persistir `service-identity.json`.
165
206
  * @param {string} [opts.label]
166
207
  * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
167
- * @returns {Promise<{device, cert, iss:string}>}
208
+ * @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
209
+ * Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
210
+ * @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
168
211
  */
169
- export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
170
- if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
212
+ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
213
+ // `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
214
+ // `dotrino-vault pair --service` emite la URL del QR y el código compacto
215
+ // (`c…`/`t…`, ver invite.js), así que un `JSON.parse` fallaba SIEMPRE con
216
+ // «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
217
+ // este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
218
+ // cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
219
+ // incluida la vieja, así que esto entiende cualquier invitación.
220
+ if (typeof qr === 'string') {
221
+ const o = parseInvite(qr)
222
+ if (!o) throw new Error('eso no parece una invitación del vault (pega la salida de `dotrino-vault pair --service <ns>`)')
223
+ qr = o
224
+ }
171
225
  if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
226
+ rechazarAdopcion(qr.m)
172
227
  if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
173
228
  if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
174
229
  label = label || 'servicio:' + ns
175
230
 
231
+ // La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
232
+ // el proxy, por ejemplo, esta llave es además su identidad de red, así que
233
+ // reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
234
+ // que se re-pineen a mano.
235
+ const anterior = readServiceIdentity(dir)
236
+ let replaced = null
237
+ if (anterior?.device?.publickey) {
238
+ replaced = {
239
+ ns: anterior.ns,
240
+ enrolledAt: anterior.enrolledAt,
241
+ deviceId: (await pubkeyId(anterior.device.publickey)).slice(0, 8).toUpperCase()
242
+ }
243
+ try { onReplace?.(replaced) } catch (_) {}
244
+ }
245
+
176
246
  const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
177
247
  // QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
178
248
  if (!qr.iss) {
@@ -201,7 +271,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
201
271
  // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
202
272
  // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
203
273
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
204
- const data = { op: 'enroll', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
274
+ // `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
275
+ // si falta, asume `join` — pero un agente no debe apoyarse en un default
276
+ // para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
277
+ // firmado: nadie en el medio puede convertirlo en una adopción.
278
+ const data = { op: 'enroll', intent: 'join', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
205
279
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
206
280
 
207
281
  const enrolled = new Promise((resolve, reject) => {
@@ -225,8 +299,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
225
299
  if (!v.ok) throw new Error('cert inválido: ' + v.reason)
226
300
  if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
227
301
 
302
+ // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
303
+ // anterior deja de existir en este agente.
228
304
  writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
229
- return { device, cert: res.cert, iss: qr.iss }
305
+ return { device, cert: res.cert, iss: qr.iss, replaced }
230
306
  } finally { client.close() }
231
307
  }
232
308