@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/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
|
|
@@ -142,9 +145,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
142
145
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
143
146
|
expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
|
|
144
147
|
})
|
|
145
|
-
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 }) }
|
|
146
149
|
const toSign = p.data?.payload
|
|
147
|
-
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' })
|
|
148
151
|
const { signature, publickey } = await identity.signData(toSign)
|
|
149
152
|
audit('sign', { device: await deviceIdOf(chk.device) })
|
|
150
153
|
reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
@@ -156,7 +159,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
156
159
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
157
160
|
expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
|
|
158
161
|
})
|
|
159
|
-
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: '
|
|
162
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
160
163
|
const id = p.data?.id || 'root'
|
|
161
164
|
reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
|
|
162
165
|
}
|
|
@@ -165,8 +168,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
165
168
|
// aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
|
|
166
169
|
async function handleStore (from, p) {
|
|
167
170
|
const d = p.data
|
|
168
|
-
|
|
169
|
-
|
|
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' })
|
|
170
176
|
}
|
|
171
177
|
if (!isFresh(d)) return staleReply(from)
|
|
172
178
|
// CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
|
|
@@ -174,14 +180,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
174
180
|
// lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
|
|
175
181
|
if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
|
|
176
182
|
audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
|
|
177
|
-
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' })
|
|
178
184
|
}
|
|
179
185
|
const revoked = await revocationSet()
|
|
180
186
|
let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, trustedIssuer: master, revoked })
|
|
181
187
|
if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
|
|
182
188
|
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
|
|
183
189
|
}
|
|
184
|
-
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: '
|
|
190
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
185
191
|
try {
|
|
186
192
|
// CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
|
|
187
193
|
// pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
|
|
@@ -190,7 +196,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
190
196
|
let cek = null
|
|
191
197
|
if (d.enc) {
|
|
192
198
|
cek = await identity.contentKey?.().catch(() => null)
|
|
193
|
-
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' })
|
|
194
200
|
args = JSON.parse(await identity.openContent(d.enc))
|
|
195
201
|
}
|
|
196
202
|
const result = await threads.methods[d.method](args)
|
|
@@ -207,7 +213,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
207
213
|
async function handleDevices (from, p) {
|
|
208
214
|
if (!isFresh(p.data)) return staleReply(from)
|
|
209
215
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
210
|
-
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: '
|
|
216
|
+
if (!chk.ok) return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
211
217
|
const { issued, revoked } = await identity.listDelegations()
|
|
212
218
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
213
219
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
@@ -236,13 +242,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
236
242
|
async function handleRenew (from, p) {
|
|
237
243
|
if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
|
|
238
244
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
239
|
-
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 }) }
|
|
240
246
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
241
247
|
const { issued } = await identity.listDelegations()
|
|
242
248
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)`)
|
|
246
267
|
reply(from, { type: MSG.RENEWED, cert })
|
|
247
268
|
}
|
|
248
269
|
|
|
@@ -256,26 +277,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
256
277
|
async function handleSecrets (from, p) {
|
|
257
278
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
258
279
|
const ns = p.data?.ns
|
|
259
|
-
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: namespace
|
|
260
|
-
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)' })
|
|
261
282
|
const chk = await verifyChain({
|
|
262
283
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
263
284
|
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
264
285
|
})
|
|
265
|
-
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 }) }
|
|
266
287
|
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
267
288
|
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
268
289
|
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
269
290
|
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
270
291
|
if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
|
|
271
292
|
audit('rejected', { what: 'secrets', ns, reason: 'cn' })
|
|
272
|
-
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` })
|
|
273
294
|
}
|
|
274
295
|
let enc
|
|
275
296
|
try {
|
|
276
297
|
enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
|
|
277
298
|
} catch (e) {
|
|
278
|
-
return reply(from, { type: MSG.ERROR, error: 'secrets: ek
|
|
299
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
279
300
|
}
|
|
280
301
|
const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
|
|
281
302
|
const { signature } = await identity.signData(body)
|
|
@@ -295,6 +316,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
295
316
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
296
317
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
297
318
|
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
319
|
+
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
298
320
|
} catch (e) {
|
|
299
321
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
300
322
|
}
|
|
@@ -305,9 +327,133 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
305
327
|
// ----- API local (CLI/UI de control) -----
|
|
306
328
|
// Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
|
|
307
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
|
+
|
|
308
454
|
// API local de secretos (solo CLI/UI del dueño; audita cada cambio).
|
|
309
|
-
function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }) }
|
|
310
|
-
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 }
|
|
311
457
|
function listSecrets () { return secrets.list() }
|
|
312
458
|
|
|
313
459
|
return {
|
|
@@ -315,7 +461,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
315
461
|
startPairing: desk.startPairing,
|
|
316
462
|
stopPairing: desk.stopPairing,
|
|
317
463
|
listPending: desk.listPending,
|
|
318
|
-
|
|
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
|
+
},
|
|
319
471
|
rejectDevice: (deviceId) => desk.reject(deviceId),
|
|
320
472
|
setSecret, deleteSecret, listSecrets,
|
|
321
473
|
listDevices: () => identity.listDelegations(),
|
|
@@ -324,8 +476,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
324
476
|
profileMembers: () => identity.profileMembers(),
|
|
325
477
|
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
326
478
|
isMaster: () => identity.isMaster(),
|
|
327
|
-
setCaps: (pub, caps) =>
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}
|
|
330
495
|
}
|
|
331
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. */
|