@dotrino/vaultd 0.12.0 → 0.14.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 +534 -188
- package/lib/README.md +136 -5
- package/lib/src/admin.js +146 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +38 -6
- package/lib/src/enroll.js +29 -29
- package/lib/src/env.js +119 -8
- package/lib/src/index.js +70 -18
- package/lib/src/protocol.js +29 -1
- package/lib/src/sealed.js +1 -1
- package/lib/src/service.js +238 -13
- package/package.json +7 -4
- package/src/atrest.js +0 -0
- package/src/client.js +64 -6
- package/src/ctl.js +74 -5
- package/src/daemon.js +53 -19
- package/src/manager.js +2 -2
- package/src/paths.js +24 -8
- package/src/profiles.js +14 -8
- package/src/secretsStore.js +14 -11
- package/src/store.js +12 -7
- package/src/threadStore.js +76 -4
- package/src/vault.js +204 -32
- package/src/vaultControl.js +8 -8
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 **
|
|
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).
|
package/lib/src/admin.js
ADDED
|
@@ -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
|
|
13
|
-
* DOTRINO_ENV_DIR
|
|
14
|
-
* DOTRINO_ENV_QUIET
|
|
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)
|
|
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:
|
|
123
|
-
if (!iss) throw new Error('createEnrollDesk:
|
|
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]
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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('
|
|
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
|
|
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
|
|
301
|
-
if (waiting.length > 1) throw new Error('
|
|
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]
|
|
310
|
-
throw new Error('
|
|
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]
|
|
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
|
|
373
|
-
if (!acta || typeof acta !== 'object') return reply(from, { type: MSG_ERROR, error: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 || '
|
|
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: '
|
|
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: '
|
|
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]
|
|
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. */
|