@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 +23 -0
- package/bin/dotrino-env.js +11 -4
- package/package.json +1 -1
- package/src/service.js +86 -10
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
|
package/bin/dotrino-env.js
CHANGED
|
@@ -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.
|
|
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
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|