@dotrino/vaultd 0.11.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/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] `true` = pisa variables ya presentes en el entorno (default: no).
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:string, secrets:Record<string,string>, injected:string[], skipped:string[]}>}
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 = false, wait = true, required = [], onRetry } = {}) {
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
- if (!override && k in process.env) { skipped.push(k); continue }
91
- process.env[k] = String(v)
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 { ns, secrets, injected, skipped }
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 = 'vault:sign'
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, approve, reject,
53
- * listPending, listMachines, revoke, getSelfCert, onPendingChange, close }
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
- const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
71
- const client = new WebSocketProxyClient({
72
- url: proxy, enableWebRTC: false, autoReconnect: true,
73
- maxReconnectAttempts: 100000, reconnectDelay: 4000
74
- })
75
- await client.connect()
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
- if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
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/invite.js CHANGED
@@ -56,9 +56,12 @@ export const FMT_COMPACT = 'c'
56
56
  * que es lo único que de verdad decide. Esconderla no aportaba nada —una pública es
57
57
  * pública— y ocupaba 44 de los ~100 caracteres.
58
58
  *
59
- * `token` aquí NO es el de la sesión de emparejamiento: es el **token de conexión**
60
- * que el proxy le da a la bóveda (4 caracteres), o sea su dirección. Con eso el
61
- * aparato le habla directo, punto a punto, sin resolver nada.
59
+ * Lo que lleva NO es la dirección de la bóveda: es una **cita del proxy** —un código
60
+ * de 6 caracteres, de un solo uso y con minutos de vida, cuyos 2 primeros dicen qué
61
+ * proxio la emitió—. El aparato la CANJEA (`redeemPairingCode`) y obtiene la dirección
62
+ * real de la conexión, que hoy son 24 caracteres para poder rutearse entre proxios y no
63
+ * cabría cómoda en un QR. Además, una cita caduca y se quema: no deja una dirección
64
+ * permanente impresa en algo que circula.
62
65
  */
63
66
  export const FMT_SHORT = 't'
64
67
 
@@ -293,7 +296,10 @@ function compactDecode (text) {
293
296
  * Le pasa a cualquiera con proxy propio (y lo cazó el E2E de secretos, que levanta
294
297
  * uno local).
295
298
  */
296
- const CONN_TOKEN_LEN = 4
299
+ // La CITA del proxio: 2 caracteres de prefijo de nodo + 4 = 6. Antes eran 4 (la
300
+ // dirección de la conexión). Cambió porque la dirección pasó a ser una instancia
301
+ // de 24 caracteres, y lo que se imprime en un QR ahora es una cita de un solo uso.
302
+ const CONN_TOKEN_LEN = 6
297
303
 
298
304
  function shortEncode (qr) {
299
305
  if (!qr || qr.v !== 2) return null
@@ -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' // leer/escribir el store de hilos + aperturas del usuario
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('sobre sellado inválido')
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))