@dotrino/vault 0.13.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 +52 -3
- package/bin/dotrino-env.js +34 -8
- package/package.json +1 -1
- package/src/config.js +17 -5
- package/src/env.js +49 -7
- package/src/service.js +86 -10
package/README.md
CHANGED
|
@@ -97,12 +97,61 @@ 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
|
+
|
|
123
|
+
### Precedencia: el vault MANDA
|
|
124
|
+
|
|
125
|
+
Los valores del vault **pisan** los del `.env` y los del entorno. El vault no
|
|
126
|
+
reemplaza al `.env` —que sigue siendo lo que arranca una máquina sin enrolar— pero
|
|
127
|
+
sí tiene la última palabra sobre las claves que administra.
|
|
128
|
+
|
|
129
|
+
Esa es la pieza que hace barata la **rotación**: cambias el valor en un solo lugar y
|
|
130
|
+
ningún `.env` viejo olvidado en un VPS puede seguir ganando. Con la precedencia al
|
|
131
|
+
revés (como estaba hasta la 0.14.0) rotar exigía además ir a limpiar cada copia
|
|
132
|
+
rancia —el trabajo que se quería evitar— y, peor, el servicio arrancaba con la llave
|
|
133
|
+
vieja **sin decir nada**.
|
|
134
|
+
|
|
135
|
+
Lo que sí se dice en voz alta: al arrancar se listan las claves que el vault tuvo que
|
|
136
|
+
pisar. Es la señal de que en esa máquina quedó un `.env` por limpiar.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
DOTRINO_ENV_OVERRIDE=0 node server.js # escotilla: por esta corrida, gana el entorno
|
|
140
|
+
dotrino-env check # dice qué claves pisaría en esta máquina
|
|
141
|
+
```
|
|
142
|
+
|
|
100
143
|
### API `@dotrino/vault/env`
|
|
101
144
|
|
|
102
|
-
- `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, skipped }`
|
|
103
|
-
(por defecto **
|
|
145
|
+
- `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, overridden, skipped }`
|
|
146
|
+
(por defecto **pisa** lo que ya esté en el entorno; `override: false` invierte la regla).
|
|
147
|
+
`overridden` son las claves que tenían otro valor y el vault reemplazó.
|
|
148
|
+
- `applyEnv(secrets, override?) → { injected, overridden, skipped }` — vuelca un bundle
|
|
149
|
+
ya obtenido, sin pedirlo. Para servicios que **no pueden bloquear su arranque**
|
|
150
|
+
esperando al vault y lo aplican cuando llega (el caso del proxy: el vault le habla
|
|
151
|
+
*por* el proxy, así que esperarlo sería un abrazo mortal).
|
|
104
152
|
- `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
|
|
105
|
-
- Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
|
|
153
|
+
- Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET` ·
|
|
154
|
+
`DOTRINO_ENV_OVERRIDE`
|
|
106
155
|
- CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
|
|
107
156
|
|
|
108
157
|
Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
|
package/bin/dotrino-env.js
CHANGED
|
@@ -16,12 +16,16 @@ import fs from 'node:fs'
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import readline from 'node:readline/promises'
|
|
18
18
|
import { spawn } from 'node:child_process'
|
|
19
|
-
import { enrollService, readServiceIdentity } from '../src/service.js'
|
|
19
|
+
import { enrollService, readServiceIdentity, fetchSecrets } from '../src/service.js'
|
|
20
20
|
import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs } from '../src/env.js'
|
|
21
|
+
// Se usaba sin importarlo: `enroll` —el comando principal— moría con
|
|
22
|
+
// «sharedParseInvite is not defined» en cuanto tocaba una invitación. Nunca
|
|
23
|
+
// llegó a funcionar, y por eso el único servicio enrolado del ecosistema (el
|
|
24
|
+
// proxy) lo hizo con su propio `enroll-vault.js` en vez de con esta CLI.
|
|
25
|
+
import { parseInvite as sharedParseInvite } from '../src/invite.js'
|
|
21
26
|
|
|
22
27
|
const argv = process.argv.slice(2)
|
|
23
28
|
const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
|
|
24
|
-
const has = (name) => argv.includes('--' + name)
|
|
25
29
|
|
|
26
30
|
function help () {
|
|
27
31
|
console.log(`dotrino-env — credenciales del vault en vez del .env
|
|
@@ -40,7 +44,11 @@ En tu código:
|
|
|
40
44
|
import '@dotrino/vault/config' // ns por DOTRINO_NS
|
|
41
45
|
import { loadEnv } from '@dotrino/vault/env'; await loadEnv({ ns: '<ns>' })
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
El vault MANDA: sus valores pisan los del .env y los del entorno. Para una
|
|
48
|
+
corrida suelta sin que pise nada: DOTRINO_ENV_OVERRIDE=0
|
|
49
|
+
|
|
50
|
+
Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET
|
|
51
|
+
DOTRINO_ENV_OVERRIDE`)
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
/**
|
|
@@ -66,9 +74,6 @@ async function cmdEnroll () {
|
|
|
66
74
|
const ns = flag('ns')
|
|
67
75
|
if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
|
|
68
76
|
const dir = flag('dir') || serviceDir(ns)
|
|
69
|
-
if (readServiceIdentity(dir) && !has('force')) {
|
|
70
|
-
console.error('ya hay un servicio enrolado en %s (usa --force para re-enrolar)', dir); process.exit(2)
|
|
71
|
-
}
|
|
72
77
|
const qr = parseInvite(flag('qr') || await readInvite())
|
|
73
78
|
|
|
74
79
|
console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
|
|
@@ -77,6 +82,17 @@ async function cmdEnroll () {
|
|
|
77
82
|
ns,
|
|
78
83
|
dir,
|
|
79
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
|
+
},
|
|
80
96
|
onCode: ({ deviceId, code }) => {
|
|
81
97
|
console.log('\n Dispositivo: %s', deviceId)
|
|
82
98
|
console.log(' APRUEBA en el vault tipeando este código:\n')
|
|
@@ -106,17 +122,27 @@ function cmdStatus () {
|
|
|
106
122
|
|
|
107
123
|
async function cmdCheck () {
|
|
108
124
|
const ns = resolveNs(flag('ns'))
|
|
109
|
-
|
|
125
|
+
// `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
|
|
126
|
+
// `loadEnv` esto inyectaría el bundle en el entorno del propio `check`, que es
|
|
127
|
+
// justo lo que un comando de diagnóstico no tiene por qué hacer.
|
|
128
|
+
const secrets = await fetchSecrets({ dir: serviceDir(ns), ns })
|
|
110
129
|
const keys = Object.keys(secrets)
|
|
111
130
|
console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
|
|
112
131
|
for (const k of keys) console.log(' ' + k) // NUNCA los valores
|
|
132
|
+
// Delata el `.env` rancio: qué claves de esta máquina el vault pisaría.
|
|
133
|
+
const chocan = keys.filter((k) => k in process.env && process.env[k] !== String(secrets[k]))
|
|
134
|
+
if (chocan.length) {
|
|
135
|
+
console.log('\nEl vault PISA estos valores del entorno de esta máquina:\n %s', chocan.join(', '))
|
|
136
|
+
}
|
|
113
137
|
}
|
|
114
138
|
|
|
115
139
|
async function cmdRun () {
|
|
116
140
|
const sep = argv.indexOf('--')
|
|
117
141
|
const cmd = sep >= 0 ? argv.slice(sep + 1) : []
|
|
118
142
|
if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
|
|
119
|
-
await loadEnv({ ns: flag('ns') })
|
|
143
|
+
const { injected, overridden } = await loadEnv({ ns: flag('ns') })
|
|
144
|
+
console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
|
|
145
|
+
overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
|
|
120
146
|
const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
|
|
121
147
|
child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
|
|
122
148
|
}
|
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/config.js
CHANGED
|
@@ -8,19 +8,31 @@
|
|
|
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
|
+
*
|
|
11
14
|
* Config por entorno:
|
|
12
|
-
* DOTRINO_NS
|
|
13
|
-
* DOTRINO_ENV_DIR
|
|
14
|
-
* DOTRINO_ENV_QUIET
|
|
15
|
+
* DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
|
|
16
|
+
* DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
|
|
17
|
+
* DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
|
|
18
|
+
* DOTRINO_ENV_OVERRIDE '0' para NO pisar el entorno en esta corrida (depuración)
|
|
15
19
|
*/
|
|
16
20
|
import { loadEnv } from './env.js'
|
|
17
21
|
|
|
18
22
|
const quiet = process.env.DOTRINO_ENV_QUIET === '1'
|
|
19
23
|
|
|
20
|
-
const { ns, injected } = await loadEnv({
|
|
24
|
+
const { ns, injected, overridden } = await loadEnv({
|
|
21
25
|
onRetry: (e, ms) => {
|
|
22
26
|
if (!quiet) console.error('[dotrino-env] vault no disponible (%s); reintentando en %ds…', e.message, Math.round(ms / 1000))
|
|
23
27
|
}
|
|
24
28
|
})
|
|
25
29
|
|
|
26
|
-
if (!quiet)
|
|
30
|
+
if (!quiet) {
|
|
31
|
+
console.error('[dotrino-env] %d valor(es) del ns "%s" cargados en process.env', injected.length, ns)
|
|
32
|
+
// Se dice en voz alta: que el vault haya tenido que pisar algo significa que
|
|
33
|
+
// en esta máquina hay un `.env` con valores viejos. Ganó el vault, pero el
|
|
34
|
+
// operador quiere enterarse — es la señal de que quedó basura por limpiar.
|
|
35
|
+
if (overridden.length) {
|
|
36
|
+
console.error('[dotrino-env] pisaron un valor previo del entorno: %s', overridden.join(', '))
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/env.js
CHANGED
|
@@ -61,19 +61,42 @@ export function resolveNs (ns) {
|
|
|
61
61
|
throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* EL VAULT MANDA: sus valores PISAN los del `.env` y los del entorno.
|
|
66
|
+
*
|
|
67
|
+
* No es el default de `dotenv` y es a propósito. El vault no viene a reemplazar
|
|
68
|
+
* al `.env` —que sigue ahí y sigue siendo el arranque de cualquier máquina sin
|
|
69
|
+
* enrolar—, viene a ser la ÚLTIMA palabra sobre las claves que administra. Esa
|
|
70
|
+
* es justamente la pieza que hace barata la rotación: se cambia el valor en un
|
|
71
|
+
* solo lugar y ningún `.env` viejo, olvidado en un VPS, puede seguir ganando.
|
|
72
|
+
* Con la precedencia al revés, rotar exigía además ir a limpiar cada copia
|
|
73
|
+
* rancia —que es exactamente el trabajo que se quería evitar—, y peor: el
|
|
74
|
+
* servicio arrancaba con la llave vieja SIN decir nada.
|
|
75
|
+
*
|
|
76
|
+
* Escotilla para depurar: `DOTRINO_ENV_OVERRIDE=0` devuelve la precedencia
|
|
77
|
+
* clásica (gana lo que ya está en el entorno) para una corrida suelta, sin
|
|
78
|
+
* tocar el vault ni el código.
|
|
79
|
+
*/
|
|
80
|
+
function overrideByDefault () {
|
|
81
|
+
return process.env.DOTRINO_ENV_OVERRIDE !== '0'
|
|
82
|
+
}
|
|
83
|
+
|
|
64
84
|
/**
|
|
65
85
|
* Trae los secretos del ns desde el vault y los pone en `process.env`.
|
|
66
86
|
*
|
|
67
87
|
* @param {Object} [opts]
|
|
68
88
|
* @param {string} [opts.ns] Namespace (por defecto: `DOTRINO_NS` o el único enrolado).
|
|
69
89
|
* @param {string} [opts.dir] Dónde está `service-identity.json` (por defecto: `serviceDir(ns)`).
|
|
70
|
-
* @param {boolean} [opts.override]
|
|
90
|
+
* @param {boolean} [opts.override] El vault pisa lo que ya esté en el entorno (default: SÍ, ver arriba).
|
|
71
91
|
* @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
|
|
72
92
|
* @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
|
|
73
93
|
* @param {(e:Error, ms:number)=>void} [opts.onRetry]
|
|
74
|
-
* @returns {Promise<{ns
|
|
94
|
+
* @returns {Promise<{ns, secrets, injected:string[], overridden:string[], skipped:string[]}>}
|
|
95
|
+
* `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
|
|
96
|
+
* el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
|
|
75
97
|
*/
|
|
76
|
-
export async function loadEnv ({ ns, dir, override
|
|
98
|
+
export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
|
|
99
|
+
if (override === undefined) override = overrideByDefault()
|
|
77
100
|
ns = resolveNs(ns)
|
|
78
101
|
dir = dir || serviceDir(ns)
|
|
79
102
|
const load = wait ? waitForSecrets : fetchSecrets
|
|
@@ -84,12 +107,31 @@ export async function loadEnv ({ ns, dir, override = false, wait = true, require
|
|
|
84
107
|
throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
return { ns, secrets, ...applyEnv(secrets, override) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Vuelca un bundle de secretos en `process.env` y cuenta qué cambió.
|
|
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.
|
|
120
|
+
*
|
|
121
|
+
* @returns {{injected:string[], overridden:string[], skipped:string[]}}
|
|
122
|
+
*/
|
|
123
|
+
export function applyEnv (secrets, override = overrideByDefault()) {
|
|
87
124
|
const injected = []
|
|
125
|
+
const overridden = []
|
|
88
126
|
const skipped = []
|
|
89
|
-
for (const [k, v] of Object.entries(secrets)) {
|
|
90
|
-
|
|
91
|
-
process.env
|
|
127
|
+
for (const [k, v] of Object.entries(secrets || {})) {
|
|
128
|
+
const previo = process.env[k]
|
|
129
|
+
const tenia = k in process.env
|
|
130
|
+
if (!override && tenia) { skipped.push(k); continue }
|
|
131
|
+
const valor = String(v)
|
|
132
|
+
process.env[k] = valor
|
|
92
133
|
injected.push(k)
|
|
134
|
+
if (tenia && previo !== valor) overridden.push(k)
|
|
93
135
|
}
|
|
94
|
-
return {
|
|
136
|
+
return { injected, overridden, skipped }
|
|
95
137
|
}
|
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
|
|