@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/README.md +534 -188
- package/bin/dotrino-vaultd.js +3 -1
- 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 +41 -35
- package/lib/src/env.js +119 -8
- package/lib/src/index.js +70 -18
- package/lib/src/invite.js +10 -4
- package/lib/src/protocol.js +29 -1
- package/lib/src/sealed.js +1 -1
- package/lib/src/service.js +263 -14
- 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 +20 -20
- 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 +208 -34
- package/src/vaultControl.js +8 -8
package/src/vault.js
CHANGED
|
@@ -15,7 +15,8 @@ import path from 'node:path'
|
|
|
15
15
|
import { Identity } from '@dotrino/identity/node'
|
|
16
16
|
import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
|
|
17
17
|
import * as Acta from '@dotrino/identity/acta'
|
|
18
|
-
import { createEnrollDesk, deviceIdOf } from '../lib/src/enroll.js'
|
|
18
|
+
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
19
|
+
import { createAdminDesk } from '../lib/src/admin.js'
|
|
19
20
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
20
21
|
import { openStore } from './store.js'
|
|
21
22
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
@@ -37,14 +38,16 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
|
37
38
|
*/
|
|
38
39
|
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
|
|
39
40
|
ensureDir(dir)
|
|
40
|
-
// CIFRADO EN REPOSO ligado a esta máquina:
|
|
41
|
-
// que
|
|
41
|
+
// CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
|
|
42
|
+
// que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
|
|
43
|
+
// antes de reemplazar); el resto —`vault.json`, `threads.json`, `secrets.json`— lo hace
|
|
44
|
+
// su propio store al abrirse. No protege contra quien ya tiene ESTA
|
|
42
45
|
// máquina (puede leer el mismo material); es subir el listón, no una imposibilidad.
|
|
43
46
|
// La migración verifica antes de reemplazar: si algo falla, el original queda intacto.
|
|
44
47
|
try {
|
|
45
48
|
const r = migrateFile(path.join(dir, 'identity.json'), machineKey(dir))
|
|
46
|
-
if (r === 'migrado') log('[vault]
|
|
47
|
-
} catch (e) { log('[vault]
|
|
49
|
+
if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
|
|
50
|
+
} catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
|
|
48
51
|
const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
|
|
49
52
|
if (!identity.me?.publickey) await identity.setMyNickname('')
|
|
50
53
|
// CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
|
|
@@ -52,7 +55,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
52
55
|
// acepte cambiar su acta recién nacida por la que traiga el dispositivo. Sin la marca,
|
|
53
56
|
// adoptar sería pisar una cuenta con datos y se rechaza — que es lo correcto por defecto.
|
|
54
57
|
if (forAdoption) {
|
|
55
|
-
try { await identity.prepareForAdoption() } catch (e) { log('[vault]
|
|
58
|
+
try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const store = openStore(dir)
|
|
@@ -69,7 +72,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
const reply = (to, obj) => {
|
|
72
|
-
try { client.send(to, obj) } catch (e) { log('[vault]
|
|
75
|
+
try { client.send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) }
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
// FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
|
|
@@ -94,7 +97,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
94
97
|
|
|
95
98
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
96
99
|
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
97
|
-
const staleReply = (from) => reply(from, { type: MSG.ERROR, error: '
|
|
100
|
+
const staleReply = (from) => reply(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
98
101
|
|
|
99
102
|
// --- ENROLL / aprobación / revocación: núcleo COMPARTIDO (lib/src/enroll.js) ---
|
|
100
103
|
// El mismo módulo lo usan «este dispositivo es bóveda» (@dotrino/vault) y la copia
|
|
@@ -113,8 +116,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
113
116
|
// ella entraría mandando una cuenta que no puede abrir.
|
|
114
117
|
encPub: identity.me?.encryptionPubkey || null,
|
|
115
118
|
vaultLabel: 'bóveda',
|
|
116
|
-
//
|
|
117
|
-
|
|
119
|
+
// Lo único que lleva el QR corto: una CITA del proxio, que es un código de 6
|
|
120
|
+
// caracteres de un solo uso y con minutos de vida. Antes iba la dirección de
|
|
121
|
+
// la conexión, que eran 4 caracteres; hoy esa dirección es una instancia de
|
|
122
|
+
// 24 (para poder rutearla entre proxios) y no cabe cómoda en un QR ni
|
|
123
|
+
// conviene dejarla impresa en algo que circula. Se pide una por
|
|
124
|
+
// emparejamiento: si el proxio es viejo y no las conoce, se cae solo a la
|
|
125
|
+
// invitación larga, que sigue funcionando.
|
|
126
|
+
connToken: async () => {
|
|
127
|
+
try { return (await client.requestPairingCode())?.code || null }
|
|
128
|
+
catch (_) { return null }
|
|
129
|
+
},
|
|
118
130
|
onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
|
|
119
131
|
defaultScope: [SCOPE.READ],
|
|
120
132
|
onChallenge ({ deviceId, scope }) {
|
|
@@ -133,9 +145,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
133
145
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
134
146
|
expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
|
|
135
147
|
})
|
|
136
|
-
if (!chk.ok) { audit('rejected', { what: 'sign', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: '
|
|
148
|
+
if (!chk.ok) { audit('rejected', { what: 'sign', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
|
|
137
149
|
const toSign = p.data?.payload
|
|
138
|
-
if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload
|
|
150
|
+
if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload required' })
|
|
139
151
|
const { signature, publickey } = await identity.signData(toSign)
|
|
140
152
|
audit('sign', { device: await deviceIdOf(chk.device) })
|
|
141
153
|
reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
@@ -147,7 +159,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
147
159
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
148
160
|
expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
|
|
149
161
|
})
|
|
150
|
-
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: '
|
|
162
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
151
163
|
const id = p.data?.id || 'root'
|
|
152
164
|
reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
|
|
153
165
|
}
|
|
@@ -156,8 +168,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
156
168
|
// aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
|
|
157
169
|
async function handleStore (from, p) {
|
|
158
170
|
const d = p.data
|
|
159
|
-
|
|
160
|
-
|
|
171
|
+
// `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
|
|
172
|
+
// `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
|
|
173
|
+
// se llamaba como si fuera del store.
|
|
174
|
+
if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
|
|
175
|
+
return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
|
|
161
176
|
}
|
|
162
177
|
if (!isFresh(d)) return staleReply(from)
|
|
163
178
|
// CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
|
|
@@ -165,14 +180,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
165
180
|
// lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
|
|
166
181
|
if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
|
|
167
182
|
audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
|
|
168
|
-
return reply(from, { type: MSG.ERROR, error: '
|
|
183
|
+
return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
|
|
169
184
|
}
|
|
170
185
|
const revoked = await revocationSet()
|
|
171
186
|
let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, trustedIssuer: master, revoked })
|
|
172
187
|
if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
|
|
173
188
|
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
|
|
174
189
|
}
|
|
175
|
-
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: '
|
|
190
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
176
191
|
try {
|
|
177
192
|
// CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
|
|
178
193
|
// pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
|
|
@@ -181,7 +196,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
181
196
|
let cek = null
|
|
182
197
|
if (d.enc) {
|
|
183
198
|
cek = await identity.contentKey?.().catch(() => null)
|
|
184
|
-
if (!cek) return reply(from, { type: MSG.ERROR, error: 'store:
|
|
199
|
+
if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key' })
|
|
185
200
|
args = JSON.parse(await identity.openContent(d.enc))
|
|
186
201
|
}
|
|
187
202
|
const result = await threads.methods[d.method](args)
|
|
@@ -198,7 +213,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
198
213
|
async function handleDevices (from, p) {
|
|
199
214
|
if (!isFresh(p.data)) return staleReply(from)
|
|
200
215
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
201
|
-
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: '
|
|
216
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
202
217
|
const { issued, revoked } = await identity.listDelegations()
|
|
203
218
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
204
219
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
@@ -227,13 +242,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
227
242
|
async function handleRenew (from, p) {
|
|
228
243
|
if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
|
|
229
244
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
230
|
-
if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: '
|
|
245
|
+
if (!chk.ok) { audit('rejected', { what: 'renew', reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
|
|
231
246
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
232
247
|
const { issued } = await identity.listDelegations()
|
|
233
248
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
// EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
|
|
250
|
+
// decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
|
|
251
|
+
// Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: dar
|
|
252
|
+
// `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
|
|
253
|
+
// QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
|
|
254
|
+
// Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
|
|
255
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
256
|
+
let scope = p.cert.scope
|
|
257
|
+
if (acta) {
|
|
258
|
+
scope = Acta.memberScopes(acta, p.cert.sub)
|
|
259
|
+
if (!scope.length) {
|
|
260
|
+
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
|
|
261
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const { cert } = await identity.signDelegation(p.cert.sub, scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
|
|
265
|
+
audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope })
|
|
266
|
+
log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} (30 days)`)
|
|
237
267
|
reply(from, { type: MSG.RENEWED, cert })
|
|
238
268
|
}
|
|
239
269
|
|
|
@@ -247,26 +277,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
247
277
|
async function handleSecrets (from, p) {
|
|
248
278
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
249
279
|
const ns = p.data?.ns
|
|
250
|
-
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: namespace
|
|
251
|
-
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets:
|
|
280
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
281
|
+
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
252
282
|
const chk = await verifyChain({
|
|
253
283
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
254
284
|
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
255
285
|
})
|
|
256
|
-
if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: '
|
|
286
|
+
if (!chk.ok) { audit('rejected', { what: 'secrets', ns, reason: chk.reason }); return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason }) }
|
|
257
287
|
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
258
288
|
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
259
289
|
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
260
290
|
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
261
291
|
if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
|
|
262
292
|
audit('rejected', { what: 'secrets', ns, reason: 'cn' })
|
|
263
|
-
return reply(from, { type: MSG.ERROR, error: `
|
|
293
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
264
294
|
}
|
|
265
295
|
let enc
|
|
266
296
|
try {
|
|
267
297
|
enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
|
|
268
298
|
} catch (e) {
|
|
269
|
-
return reply(from, { type: MSG.ERROR, error: 'secrets: ek
|
|
299
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
270
300
|
}
|
|
271
301
|
const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
|
|
272
302
|
const { signature } = await identity.signData(body)
|
|
@@ -286,6 +316,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
286
316
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
287
317
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
288
318
|
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
319
|
+
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
289
320
|
} catch (e) {
|
|
290
321
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
291
322
|
}
|
|
@@ -296,9 +327,133 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
296
327
|
// ----- API local (CLI/UI de control) -----
|
|
297
328
|
// Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
|
|
298
329
|
|
|
330
|
+
// ----- AVISO DE CAMBIO a los agentes del ns -----
|
|
331
|
+
//
|
|
332
|
+
// Guardar un secreto no sirve de nada si quien lo usa no se entera. La bóveda
|
|
333
|
+
// avisa (sin mandar valores: solo «el ns cambió») y el agente decide — el
|
|
334
|
+
// estándar es que SALGA y lo levante su supervisor, para leer todo fresco y,
|
|
335
|
+
// sobre todo, para que el valor viejo deje de existir en su memoria.
|
|
336
|
+
//
|
|
337
|
+
// AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
|
|
338
|
+
// escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
|
|
339
|
+
// reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
|
|
340
|
+
const AVISO_AGRUPA_MS = Number(process.env.DOTRINO_VAULT_AVISO_MS) || 3000
|
|
341
|
+
const avisosPendientes = new Map() // ns → timer
|
|
342
|
+
|
|
343
|
+
async function avisarCambio (ns) {
|
|
344
|
+
let destinos = []
|
|
345
|
+
try {
|
|
346
|
+
const { issued } = await identity.listDelegations()
|
|
347
|
+
const revocados = await revocationSet()
|
|
348
|
+
const scope = secretsScope(ns)
|
|
349
|
+
// Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
|
|
350
|
+
// que mandárselo a otro sería filtrarle que existe.
|
|
351
|
+
//
|
|
352
|
+
// Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
|
|
353
|
+
// delegación nueva para la MISMA sub-clave, así que un agente que lleve
|
|
354
|
+
// tiempo enrolado aparece varias veces y recibiría el aviso repetido.
|
|
355
|
+
const vistas = new Set()
|
|
356
|
+
destinos = (issued || []).filter((x) => {
|
|
357
|
+
if (!x.sub || revocados.has(x.nonce) || !(x.scope || []).includes(scope)) return false
|
|
358
|
+
if (vistas.has(x.sub)) return false
|
|
359
|
+
vistas.add(x.sub)
|
|
360
|
+
return true
|
|
361
|
+
})
|
|
362
|
+
} catch (e) { return log('[vault] could not list who to notify:', e.message) }
|
|
363
|
+
if (!destinos.length) return
|
|
364
|
+
|
|
365
|
+
const body = { op: 'secrets.changed', ns, ts: Date.now() }
|
|
366
|
+
const { signature } = await identity.signData(body)
|
|
367
|
+
for (const d of destinos) {
|
|
368
|
+
try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
|
|
369
|
+
}
|
|
370
|
+
audit('secrets.changed', { ns, avisados: destinos.length })
|
|
371
|
+
log(`[vault] config for "${ns}" changed: notified ${destinos.length} agent(s)`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function programarAviso (ns) {
|
|
375
|
+
clearTimeout(avisosPendientes.get(ns))
|
|
376
|
+
const t = setTimeout(() => {
|
|
377
|
+
avisosPendientes.delete(ns)
|
|
378
|
+
avisarCambio(ns).catch((e) => log('[vault] change notice failed:', e.message))
|
|
379
|
+
}, AVISO_AGRUPA_MS)
|
|
380
|
+
t.unref?.()
|
|
381
|
+
avisosPendientes.set(ns, t)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
|
|
385
|
+
// Un dispositivo con cert `vault:admin` puede ADMITIR y EXPULSAR miembros sin venir
|
|
386
|
+
// al PC. No puede cambiar permisos, traspasar el mando, conceder `admin` ni tocar los
|
|
387
|
+
// secretos: esas operaciones no existen como mensaje, a propósito. Así un aparato con
|
|
388
|
+
// `admin` robado hace daño acotado y reversible (se le revoca) en vez de poder dejar
|
|
389
|
+
// al dueño fuera de su propia cuenta, que no tiene vuelta atrás.
|
|
390
|
+
/** Últimas entradas de la bitácora (JSONL), de la más reciente hacia atrás. */
|
|
391
|
+
function readActivity (limit = 100) {
|
|
392
|
+
try {
|
|
393
|
+
return fs.readFileSync(activityFile, 'utf8').split('\n').filter(Boolean).slice(-limit)
|
|
394
|
+
.map((l) => { try { return JSON.parse(l) } catch (_) { return null } }).filter(Boolean).reverse()
|
|
395
|
+
} catch (_) { return [] }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Avisa a TODOS los miembros de que el perfil cambió, firmado por la maestra. Sin
|
|
400
|
+
* esto, administrar a distancia sería invisible para el resto de los dispositivos —
|
|
401
|
+
* y esa visibilidad es lo que hace DETECTABLE a un admin comprometido. Va por
|
|
402
|
+
* `sendByPubkey`, así que al que está apagado le llega cuando encienda (cola 24 h).
|
|
403
|
+
*/
|
|
404
|
+
async function notifyMembers (ev, info = {}) {
|
|
405
|
+
try {
|
|
406
|
+
const body = { ev, ...info, ts: Date.now() }
|
|
407
|
+
const { signature } = await identity.signData(body)
|
|
408
|
+
const { issued } = await identity.listDelegations()
|
|
409
|
+
// UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
|
|
410
|
+
// MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
|
|
411
|
+
// y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
|
|
412
|
+
// cuidado que en `avisarCambio`.
|
|
413
|
+
const vistas = new Set()
|
|
414
|
+
for (const d of issued || []) {
|
|
415
|
+
if (!d.sub || vistas.has(d.sub)) continue
|
|
416
|
+
vistas.add(d.sub)
|
|
417
|
+
try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
|
|
418
|
+
}
|
|
419
|
+
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const admin = createAdminDesk({
|
|
423
|
+
desk,
|
|
424
|
+
deviceIdOf,
|
|
425
|
+
ttlMs: DEVICE_TTL_MS,
|
|
426
|
+
audit,
|
|
427
|
+
notify: notifyMembers,
|
|
428
|
+
readActivity,
|
|
429
|
+
// CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
|
|
430
|
+
// qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
|
|
431
|
+
// hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
|
|
432
|
+
// fiar exigía revocarlo entero. Con el cruce, deja de administrar en el acto.
|
|
433
|
+
verify: async ({ data, signature, cert }) => {
|
|
434
|
+
const chk = await verifyChain({
|
|
435
|
+
data, signature, cert,
|
|
436
|
+
expectedScope: SCOPE.ADMIN, trustedIssuer: master, revoked: await revocationSet()
|
|
437
|
+
})
|
|
438
|
+
if (!chk.ok) return chk
|
|
439
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
440
|
+
if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
|
|
441
|
+
return chk
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
async function handleAdmin (from, p) {
|
|
446
|
+
// La frescura se comprueba aquí (es del transporte, igual que en el resto de
|
|
447
|
+
// handlers); el resto de la regla vive en el módulo puro.
|
|
448
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
449
|
+
const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
|
|
450
|
+
if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error })
|
|
451
|
+
reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
|
|
452
|
+
}
|
|
453
|
+
|
|
299
454
|
// API local de secretos (solo CLI/UI del dueño; audita cada cambio).
|
|
300
|
-
function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }) }
|
|
301
|
-
function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) audit('secret.rm', { ns, key }); return ok }
|
|
455
|
+
function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }); programarAviso(ns) }
|
|
456
|
+
function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); programarAviso(ns) } return ok }
|
|
302
457
|
function listSecrets () { return secrets.list() }
|
|
303
458
|
|
|
304
459
|
return {
|
|
@@ -306,7 +461,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
306
461
|
startPairing: desk.startPairing,
|
|
307
462
|
stopPairing: desk.stopPairing,
|
|
308
463
|
listPending: desk.listPending,
|
|
309
|
-
|
|
464
|
+
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
465
|
+
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
466
|
+
approveDevice: async (code) => {
|
|
467
|
+
const r = await desk.approve(code)
|
|
468
|
+
await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
|
|
469
|
+
return r
|
|
470
|
+
},
|
|
310
471
|
rejectDevice: (deviceId) => desk.reject(deviceId),
|
|
311
472
|
setSecret, deleteSecret, listSecrets,
|
|
312
473
|
listDevices: () => identity.listDelegations(),
|
|
@@ -315,8 +476,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
315
476
|
profileMembers: () => identity.profileMembers(),
|
|
316
477
|
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
317
478
|
isMaster: () => identity.isMaster(),
|
|
318
|
-
setCaps: (pub, caps) =>
|
|
319
|
-
|
|
320
|
-
|
|
479
|
+
setCaps: async (pub, caps) => {
|
|
480
|
+
const r = await identity.setCaps(pub, caps)
|
|
481
|
+
audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
|
|
482
|
+
await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
|
|
483
|
+
return r
|
|
484
|
+
},
|
|
485
|
+
revokeDevice: async (nonce) => {
|
|
486
|
+
const r = await desk.revoke(nonce)
|
|
487
|
+
await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
|
|
488
|
+
return r
|
|
489
|
+
},
|
|
490
|
+
close () {
|
|
491
|
+
for (const t of avisosPendientes.values()) clearTimeout(t)
|
|
492
|
+
avisosPendientes.clear()
|
|
493
|
+
try { client.close() } catch (_) {} identity.destroy()
|
|
494
|
+
}
|
|
321
495
|
}
|
|
322
496
|
}
|
package/src/vaultControl.js
CHANGED
|
@@ -149,7 +149,7 @@ async function profileOp (op, { profile, name, password } = {}) {
|
|
|
149
149
|
writeReq(F.profileReq, { op, ...extra }, profile)
|
|
150
150
|
signalOrCleanup('SIGUSR2', [F.profileReq])
|
|
151
151
|
const d = await waitFor(F.profilesList)
|
|
152
|
-
if (!d) throw coded('
|
|
152
|
+
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
153
153
|
if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
|
|
154
154
|
return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
|
|
155
155
|
}
|
|
@@ -189,7 +189,7 @@ export async function snapshot (profile) {
|
|
|
189
189
|
*/
|
|
190
190
|
export async function listDevices (profile) {
|
|
191
191
|
const { devices } = await snapshot(profile)
|
|
192
|
-
if (!devices) throw coded('
|
|
192
|
+
if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
193
193
|
const issued = devices.issued || devices.active || devices.delegations || []
|
|
194
194
|
const withIds = await Promise.all(issued.map(async (d) => ({
|
|
195
195
|
...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
|
|
@@ -213,7 +213,7 @@ export async function revokeDevice (nonce, profile) {
|
|
|
213
213
|
/** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
|
|
214
214
|
export async function listSecrets (profile) {
|
|
215
215
|
const { secrets } = await snapshot(profile)
|
|
216
|
-
if (!secrets) throw coded('
|
|
216
|
+
if (!secrets) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
217
217
|
return secrets.ns || {}
|
|
218
218
|
}
|
|
219
219
|
|
|
@@ -225,8 +225,8 @@ export async function setSecret (ns, key, value, profile) {
|
|
|
225
225
|
writeReq(F.dumpReq, {}, profile)
|
|
226
226
|
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
227
227
|
const d = await waitFor(F.secretsList)
|
|
228
|
-
if (!d) throw coded('
|
|
229
|
-
if (!(d.ns?.[ns] || []).includes(key)) throw coded('
|
|
228
|
+
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
229
|
+
if (!(d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
230
230
|
return d.ns
|
|
231
231
|
}
|
|
232
232
|
|
|
@@ -238,8 +238,8 @@ export async function deleteSecret (ns, key, profile) {
|
|
|
238
238
|
writeReq(F.dumpReq, {}, profile)
|
|
239
239
|
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
240
240
|
const d = await waitFor(F.secretsList)
|
|
241
|
-
if (!d) throw coded('
|
|
242
|
-
if ((d.ns?.[ns] || []).includes(key)) throw coded('
|
|
241
|
+
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
242
|
+
if ((d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
|
|
243
243
|
return d.ns
|
|
244
244
|
}
|
|
245
245
|
|
|
@@ -292,7 +292,7 @@ export async function startPairing ({ profile, service } = {}) {
|
|
|
292
292
|
return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload, code, b64: code, profile: pr.profile || null, profileName: pr.profileName || '' }
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
|
-
throw coded('
|
|
295
|
+
throw coded('the daemon did not start the pairing', 'PAIR_FAILED')
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/** Dispositivo pendiente de aprobar (el que se conectó con el QR), o null. */
|