@dotrino/vault 0.14.0 → 0.15.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
@@ -97,6 +97,29 @@ Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
97
97
  dotrino-env run --ns miapp -- ./mi-binario
98
98
  ```
99
99
 
100
+ ### Un agente tiene UNA identidad, y se la da el vault
101
+
102
+ Un **aparato** puede llevar varios perfiles, y hasta meter su propia cuenta al vault
103
+ por adopción: llega con una historia que conservar. Un **agente** no es eso. Es un
104
+ servicio: su identidad se la cede el vault y no hay caso en que quiera empujar la
105
+ suya hacia arriba. De ahí tres reglas, que el paquete aplica solas:
106
+
107
+ - **No adopta, nunca.** La invitación declara su modo (`join` / `adopt`); si viene
108
+ abierta para adoptar, `enrollService` la **rechaza al pegarla**, sin salir a la red.
109
+ La intención `join` va además firmada dentro de la petición, para que nadie en el
110
+ medio la convierta en otra cosa.
111
+ - **No acumula.** Enrolar de nuevo **reemplaza** la identidad anterior, que deja de
112
+ existir en ese agente. No es un error a desbloquear con `--force`: es la forma de
113
+ **rotar** la identidad de un agente comprometido. Se avisa por `onReplace` qué se
114
+ descarta.
115
+ - **Un agente, un `ns`.** Varios agentes pueden convivir en una máquina (un
116
+ directorio por namespace); lo que no existe es un agente que sea varios.
117
+
118
+ > ⚠ Si esa llave es además la **identidad de red** del servicio —el caso del proxio,
119
+ > cuyo id de nodo se deriva de ella— reemplazarla le cambia el nombre en la red: las
120
+ > instancias y citas vivas dejan de resolver y los peers que lo tenían pineado lo
121
+ > rechazan hasta re-pinearlo. Es a propósito: así se echa a un nodo comprometido.
122
+
100
123
  ### Precedencia: el vault MANDA
101
124
 
102
125
  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.0",
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/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