@dotrino/vaultd 0.12.0 → 0.13.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 +10 -5
- package/src/daemon.js +19 -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 +197 -32
- package/src/vaultControl.js +8 -8
package/lib/src/env.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs'
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
|
-
import { fetchSecrets, waitForSecrets, readServiceIdentity } from './service.js'
|
|
24
|
+
import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges } from './service.js'
|
|
25
25
|
import { isValidSecretsNs } from './protocol.js'
|
|
26
26
|
|
|
27
27
|
/** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
|
|
@@ -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,100 @@ 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
|
+
* ⚠ 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.
|
|
129
|
+
*
|
|
130
|
+
* @returns {{injected:string[], overridden:string[], skipped:string[]}}
|
|
131
|
+
*/
|
|
132
|
+
export function applyEnv (secrets, override = overrideByDefault()) {
|
|
87
133
|
const injected = []
|
|
134
|
+
const overridden = []
|
|
88
135
|
const skipped = []
|
|
89
|
-
for (const [k, v] of Object.entries(secrets)) {
|
|
90
|
-
|
|
91
|
-
process.env
|
|
136
|
+
for (const [k, v] of Object.entries(secrets || {})) {
|
|
137
|
+
const previo = process.env[k]
|
|
138
|
+
const tenia = k in process.env
|
|
139
|
+
if (!override && tenia) { skipped.push(k); continue }
|
|
140
|
+
const valor = String(v)
|
|
141
|
+
process.env[k] = valor
|
|
92
142
|
injected.push(k)
|
|
143
|
+
if (tenia && previo !== valor) overridden.push(k)
|
|
93
144
|
}
|
|
94
|
-
return {
|
|
145
|
+
return { injected, overridden, skipped }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Queda a la escucha de la bóveda y, cuando avisa que la configuración cambió,
|
|
150
|
+
* TERMINA EL PROCESO para que el supervisor lo levante con todo fresco.
|
|
151
|
+
*
|
|
152
|
+
* Por qué salir en vez de recargar en caliente:
|
|
153
|
+
*
|
|
154
|
+
* 1. **Borra de memoria el valor viejo.** Es la razón de peso. En JavaScript un
|
|
155
|
+
* secreto no se puede borrar: los strings son inmutables, no hay `zeroize`, y
|
|
156
|
+
* el valor sigue en el heap hasta que al recolector le apetezca — más lo que
|
|
157
|
+
* capturó cada closure y cada caché derivada. Y una llave se rota casi siempre
|
|
158
|
+
* PORQUE SE FILTRÓ, así que dejarla viva en el proceso anula el motivo de
|
|
159
|
+
* rotarla. Un proceso nuevo 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. **Sirve de interruptor de emergencia.** Revocar el cert de un agente ya no
|
|
164
|
+
* espera a que alguien se acuerde de reiniciarlo: se apaga, y al arrancar
|
|
165
|
+
* `fetchSecrets` recibe «no autorizado: revoked» y no vuelve.
|
|
166
|
+
*
|
|
167
|
+
* NO se reinicia solo: sale, y lo levanta quien lo supervisa (pm2, systemd con
|
|
168
|
+
* `Restart=always`). Un proceso no puede reiniciarse a sí mismo de forma fiable, y
|
|
169
|
+
* el supervisor ya trae backoff y tope de intentos, que es justo lo que evita que
|
|
170
|
+
* una configuración rota se convierta en un ciclo.
|
|
171
|
+
*
|
|
172
|
+
* @param {Object} [opts]
|
|
173
|
+
* @param {string} [opts.ns]
|
|
174
|
+
* @param {string} [opts.dir]
|
|
175
|
+
* @param {(info:{ns:string, ts:number, motivo:'cambio'|'revocado'})=>void} [opts.onUpdate]
|
|
176
|
+
* Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
|
|
177
|
+
* opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
|
|
178
|
+
* @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
|
|
179
|
+
* no levantaría un servicio que sale con 0, pero `Restart=always` sí, y pm2
|
|
180
|
+
* también. Se elige 0 porque salir a propósito no es un fallo.
|
|
181
|
+
* @returns {Promise<{stop:()=>void}>}
|
|
182
|
+
*/
|
|
183
|
+
export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...resto } = {}) {
|
|
184
|
+
ns = resolveNs(ns)
|
|
185
|
+
dir = dir || serviceDir(ns)
|
|
186
|
+
const decir = (m) => { if (!quiet) console.error(m) }
|
|
187
|
+
|
|
188
|
+
const salir = (motivo) => {
|
|
189
|
+
decir(`[dotrino-env] ${motivo === 'revocado'
|
|
190
|
+
? 'la bóveda REVOCÓ este agente: terminando (no volverá a arrancar)'
|
|
191
|
+
: 'configuración nueva en la bóveda: terminando para que el supervisor lo levante limpio'}`)
|
|
192
|
+
process.exit(motivo === 'revocado' ? 1 : exitCode)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return watchSecretsChanges({
|
|
196
|
+
dir,
|
|
197
|
+
ns,
|
|
198
|
+
log: decir,
|
|
199
|
+
onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, motivo: 'cambio' }) : salir('cambio')),
|
|
200
|
+
// Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
|
|
201
|
+
// levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
|
|
202
|
+
// reinicios fallidos es lo que hace que se note en vez de girar en silencio.
|
|
203
|
+
onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), motivo: 'revocado' }) : salir('revocado')),
|
|
204
|
+
...resto
|
|
205
|
+
})
|
|
95
206
|
}
|
package/lib/src/index.js
CHANGED
|
@@ -27,16 +27,13 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
29
29
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
|
+
// Las constantes del protocolo salen del MISMO módulo que usa el daemon: si la lista
|
|
31
|
+
// local se queda corta, el dispositivo deja de atender mensajes sin que nadie lo note.
|
|
32
|
+
import { MSG, SCOPE } from './protocol.js'
|
|
30
33
|
|
|
31
|
-
const SIGN_SCOPE =
|
|
34
|
+
const SIGN_SCOPE = SCOPE.SIGN
|
|
32
35
|
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
33
|
-
|
|
34
|
-
const MSG = {
|
|
35
|
-
ENROLL: 'vault.enroll',
|
|
36
|
-
DEVICES: 'vault.devices',
|
|
37
|
-
DEVICES_RESULT: 'vault.devices.result',
|
|
38
|
-
ERROR: 'vault.error'
|
|
39
|
-
}
|
|
36
|
+
const RENEW_TTL_MS = DEVICE_TTL_MS // la renovación extiende la misma ventana (30 días)
|
|
40
37
|
|
|
41
38
|
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
42
39
|
export { deviceIdOf }
|
|
@@ -49,10 +46,11 @@ export { deviceIdOf }
|
|
|
49
46
|
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
50
47
|
* @param {object} [opts]
|
|
51
48
|
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
52
|
-
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing,
|
|
53
|
-
* listPending, listMachines, revoke, getSelfCert, onPendingChange,
|
|
49
|
+
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, stopPairing,
|
|
50
|
+
* approve, reject, listPending, listMachines, revoke, getSelfCert, onPendingChange,
|
|
51
|
+
* onAdopted, close }
|
|
54
52
|
*/
|
|
55
|
-
export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
53
|
+
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
56
54
|
const iss = identity.me?.publickey
|
|
57
55
|
if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
|
|
58
56
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
@@ -67,12 +65,17 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
67
65
|
return cert
|
|
68
66
|
}
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
// `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
|
|
69
|
+
// levanta el del ecosistema — no hay otro transporte.
|
|
70
|
+
const client = injectedClient || await (async () => {
|
|
71
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
72
|
+
const c = new WebSocketProxyClient({
|
|
73
|
+
url: proxy, enableWebRTC: false, autoReconnect: true,
|
|
74
|
+
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
75
|
+
})
|
|
76
|
+
await c.connect()
|
|
77
|
+
return c
|
|
78
|
+
})()
|
|
76
79
|
|
|
77
80
|
const selfCert = await getSelfCert()
|
|
78
81
|
const identify = async () => {
|
|
@@ -87,6 +90,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
87
90
|
const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
|
|
88
91
|
|
|
89
92
|
let _onPendingChange = () => {}
|
|
93
|
+
let _onAdopted = () => {}
|
|
90
94
|
|
|
91
95
|
// ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
|
|
92
96
|
// copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
|
|
@@ -99,9 +103,49 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
99
103
|
sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
|
|
100
104
|
defaultScope: [SIGN_SCOPE],
|
|
101
105
|
defaultTtlMs: DEVICE_TTL_MS,
|
|
106
|
+
// Camino A (la cuenta del aparato pasa a vivir aquí): sin la llave de cifrado, esta
|
|
107
|
+
// bóveda entraría mandando una cuenta cuyo contenido no puede abrir.
|
|
108
|
+
encPub: identity.me?.encryptionPubkey || null,
|
|
109
|
+
vaultLabel: 'bóveda',
|
|
110
|
+
// Cita del proxio para la invitación corta (QR). Si el proxio es viejo y no las
|
|
111
|
+
// conoce, el desk se cae solo a la invitación larga.
|
|
112
|
+
connToken: async () => {
|
|
113
|
+
try { return (await client.requestPairingCode())?.code || null }
|
|
114
|
+
catch (_) { return null }
|
|
115
|
+
},
|
|
116
|
+
onAdopted: (info) => { try { _onAdopted(info) } catch (_) {} },
|
|
102
117
|
onPendingChange: () => _onPendingChange()
|
|
103
118
|
})
|
|
104
119
|
|
|
120
|
+
/** Nonces revocados, para que un cert revocado no pase ningún `verifyChain`. */
|
|
121
|
+
async function revocationSet () {
|
|
122
|
+
const { revoked } = await identity.listDelegations()
|
|
123
|
+
return new Set((revoked || []).map((r) => r.nonce || r))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* RENOVACIÓN automática (igual que `dotrino-vault#handleRenew`): un dispositivo con
|
|
128
|
+
* cert VIGENTE y no revocado pide uno fresco —misma sub-clave y scope— sin QR ni
|
|
129
|
+
* aprobación: sigue siendo el mismo dispositivo, solo extiende la ventana. Un cert
|
|
130
|
+
* vencido o revocado NO se renueva (ahí toca re-emparejar con aprobación).
|
|
131
|
+
*
|
|
132
|
+
* Sin esto, toda máquina enrolada contra un dispositivo-bóveda caduca a los 30 días.
|
|
133
|
+
*/
|
|
134
|
+
async function handleRenew (from, p) {
|
|
135
|
+
const d = p?.data
|
|
136
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
|
|
137
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
|
+
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
|
+
}
|
|
140
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss, revoked: await revocationSet() })
|
|
141
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
142
|
+
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
143
|
+
const { issued } = await identity.listDelegations()
|
|
144
|
+
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
145
|
+
const { cert } = await identity.signDelegation(p.cert.sub, p.cert.scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
|
|
146
|
+
send(from, { type: MSG.RENEWED, cert })
|
|
147
|
+
}
|
|
148
|
+
|
|
105
149
|
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
106
150
|
// de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
|
|
107
151
|
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
@@ -124,7 +168,12 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
124
168
|
|
|
125
169
|
client.on('message', (_from, p) => {
|
|
126
170
|
if (!p || typeof p !== 'object') return
|
|
127
|
-
|
|
171
|
+
// El QR corto no lleva la llave: el aparato la pide con un HELLO presentando el `sn`.
|
|
172
|
+
if (p.type === MSG.HELLO) Promise.resolve(desk.handleHello(_from, p)).catch(() => {})
|
|
173
|
+
else if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
|
|
174
|
+
// Camino A: el aparato devuelve su acta sellada admitiendo a esta bóveda.
|
|
175
|
+
else if (p.type === MSG.ACTA_SEALED) Promise.resolve(desk.handleActaSealed(_from, p)).catch(() => {})
|
|
176
|
+
else if (p.type === MSG.RENEW) handleRenew(_from, p).catch(() => {})
|
|
128
177
|
else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
|
|
129
178
|
})
|
|
130
179
|
|
|
@@ -148,6 +197,7 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
148
197
|
return {
|
|
149
198
|
iss, proxy, client,
|
|
150
199
|
startPairing: desk.startPairing,
|
|
200
|
+
stopPairing: desk.stopPairing,
|
|
151
201
|
// Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
|
|
152
202
|
// el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
|
|
153
203
|
approve: (deviceId, code) => desk.approve(code, { deviceId }),
|
|
@@ -159,6 +209,8 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
|
159
209
|
revoke: (nonce) => desk.revoke(nonce),
|
|
160
210
|
getSelfCert,
|
|
161
211
|
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
212
|
+
/** Camino A: la cuenta del aparato quedó adoptada por esta bóveda. */
|
|
213
|
+
onAdopted (fn) { _onAdopted = fn || (() => {}) },
|
|
162
214
|
close () { try { client.close() } catch (_) {} }
|
|
163
215
|
}
|
|
164
216
|
}
|
package/lib/src/protocol.js
CHANGED
|
@@ -45,6 +45,30 @@ export const MSG = Object.freeze({
|
|
|
45
45
|
RENEWED: 'vault.renewed', // vault → dispositivo: { cert } (cert fresco, misma sub-clave/scope)
|
|
46
46
|
SECRETS: 'vault.secrets', // servicio → vault: { data:{op,ns,ek,publickey,ts}, signature, cert }
|
|
47
47
|
SECRETS_RESULT: 'vault.secrets.result', // vault → servicio: { body:{op,ns,enc,ts}, signature } (enc SELLADO a ek; body firmado por la maestra)
|
|
48
|
+
// AVISO DE CAMBIO (no lleva valores): la bóveda dice «la configuración del ns
|
|
49
|
+
// cambió». El agente no la recarga en caliente — SALE limpio y su supervisor lo
|
|
50
|
+
// levanta. Dos razones, y la segunda es la de peso:
|
|
51
|
+
// · Lee todo fresco. Recargar en caliente exige que cada sitio que leyó una
|
|
52
|
+
// variable sepa releerla, y esa lista hay que mantenerla para siempre.
|
|
53
|
+
// · BORRA DE MEMORIA EL VALOR VIEJO. En JavaScript un secreto no se puede
|
|
54
|
+
// borrar: los strings son inmutables, no hay zeroize, y el valor queda en el
|
|
55
|
+
// heap hasta que al recolector le apetezca — más lo que capturó cada closure
|
|
56
|
+
// y cada caché derivada. Una llave se rota casi siempre PORQUE SE FILTRÓ, así
|
|
57
|
+
// que dejarla viva en el proceso anula la razón de rotarla. Un proceso nuevo
|
|
58
|
+
// empieza con el heap limpio.
|
|
59
|
+
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
60
|
+
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
61
|
+
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
|
|
62
|
+
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
63
|
+
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
64
|
+
// Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
|
|
65
|
+
// NO se exponen aquí, y no es un olvido — es el límite (§2 del diseño).
|
|
66
|
+
ADMIN: 'vault.admin', // admin → vault: { data:{op,…,ts,nonce}, signature, cert }
|
|
67
|
+
ADMIN_RESULT: 'vault.admin.result', // vault → admin: { op, result }
|
|
68
|
+
// Aviso a TODOS los miembros de que el perfil cambió (alguien entró o salió). Es la
|
|
69
|
+
// contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
|
|
70
|
+
// invisible para el resto de tus dispositivos.
|
|
71
|
+
ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
|
|
48
72
|
ERROR: 'vault.error' // vault → dispositivo: { error }
|
|
49
73
|
})
|
|
50
74
|
|
|
@@ -52,7 +76,11 @@ export const MSG = Object.freeze({
|
|
|
52
76
|
export const SCOPE = Object.freeze({
|
|
53
77
|
SIGN: 'vault:sign', // pedir a la maestra que firme datos (identidad)
|
|
54
78
|
READ: 'vault:read', // leer nodos del árbol de contenidos
|
|
55
|
-
STORE: 'vault:store'
|
|
79
|
+
STORE: 'vault:store', // leer/escribir el store de hilos + aperturas del usuario
|
|
80
|
+
// Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
|
|
81
|
+
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
82
|
+
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
83
|
+
ADMIN: 'vault:admin'
|
|
56
84
|
})
|
|
57
85
|
|
|
58
86
|
/**
|
package/lib/src/sealed.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function seal ({ ek, payload }) {
|
|
|
76
76
|
*/
|
|
77
77
|
export async function openSealed ({ privateKey, enc }) {
|
|
78
78
|
if (!enc || typeof enc.epk !== 'string' || typeof enc.iv !== 'string' || typeof enc.ct !== 'string') {
|
|
79
|
-
throw new Error('
|
|
79
|
+
throw new Error('invalid sealed envelope')
|
|
80
80
|
}
|
|
81
81
|
const key = await deriveAesKey(privateKey, enc.epk)
|
|
82
82
|
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(enc.iv) }, key, fromB64(enc.ct))
|