@dotrino/vaultd 0.26.2 → 0.38.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 +142 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +88 -3
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +26 -22
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +12 -0
- package/lib/src/service.js +218 -92
- package/package.json +9 -4
- package/src/ctl.js +375 -90
- package/src/daemon.js +167 -45
- package/src/manager.js +6 -6
- package/src/profiles.js +27 -4
- package/src/secretsStore.js +191 -25
- package/src/transport.js +2 -2
- package/src/tui/app.js +512 -126
- package/src/tui/i18n.js +118 -22
- package/src/vault.js +351 -44
- package/src/vaultControl.js +253 -64
package/src/vault.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
import { Identity } from '@dotrino/identity/node'
|
|
16
|
-
import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
|
|
16
|
+
import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
17
17
|
import * as Acta from '@dotrino/identity/acta'
|
|
18
18
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
19
19
|
import { createAdminDesk } from '../lib/src/admin.js'
|
|
@@ -21,7 +21,7 @@ import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
|
21
21
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
22
22
|
import { openStore } from './store.js'
|
|
23
23
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
24
|
-
import { openSecretsStore } from './secretsStore.js'
|
|
24
|
+
import { openSecretsStore, assertVar } from './secretsStore.js'
|
|
25
25
|
import { seal } from '../lib/src/sealed.js'
|
|
26
26
|
import { dataDir, ensureDir } from './paths.js'
|
|
27
27
|
import { atRestFor, machineKey, migrateFile } from './atrest.js'
|
|
@@ -129,6 +129,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
129
129
|
catch (_) { return null }
|
|
130
130
|
},
|
|
131
131
|
onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
|
|
132
|
+
// Se va el aparato, se van SUS variables. Guardarlas sería configuración de una llave
|
|
133
|
+
// que ya no entra, y volvería a la vida sola el día que se enrole otro aparato con esa
|
|
134
|
+
// misma llave. Va aquí porque a quitar se entra por dos puertas (el PC y la consola
|
|
135
|
+
// remota) y las dos pasan por `desk.revokeDevice`.
|
|
136
|
+
onDeviceRemoved: (sub) => {
|
|
137
|
+
try {
|
|
138
|
+
const n = secrets.forgetDevice(sub)
|
|
139
|
+
if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
|
|
140
|
+
} catch (e) { log('[vault] could not drop the device variables:', e.message) }
|
|
141
|
+
},
|
|
132
142
|
defaultScope: [SCOPE.READ],
|
|
133
143
|
onChallenge ({ deviceId, scope }) {
|
|
134
144
|
log(`\n[vault] Un dispositivo quiere conectarse:`)
|
|
@@ -273,6 +283,37 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
273
283
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
274
284
|
}
|
|
275
285
|
|
|
286
|
+
/**
|
|
287
|
+
* «¿SIGO SIENDO DE ESTA CASA?» — la única pregunta que se atiende SIN certificado.
|
|
288
|
+
*
|
|
289
|
+
* Existe por el aparato que se quedó sin papel: no puede firmar, ni leer, ni renovar, y
|
|
290
|
+
* —esto es lo grave— tampoco tenía forma de enterarse de que lo echaron, porque todo lo
|
|
291
|
+
* demás exige el certificado que ya no tiene. Se quedaba enseñando para siempre una
|
|
292
|
+
* cuenta que ya no era suya. Va firmada con la llave del propio aparato, que es
|
|
293
|
+
* exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
|
|
294
|
+
*
|
|
295
|
+
* La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
|
|
296
|
+
* pidió, y contarle el perfil entero a quien no trae papel es dar de más—. Al que ya no
|
|
297
|
+
* está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
|
|
298
|
+
* Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
|
|
299
|
+
* nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
|
|
300
|
+
* el aviso nombre al aparato que lo recibe).
|
|
301
|
+
*/
|
|
302
|
+
async function handleCheck (from, p) {
|
|
303
|
+
if (!isFresh(p?.data)) return staleReply(from)
|
|
304
|
+
const pub = p.data.publickey
|
|
305
|
+
if (typeof pub !== 'string') return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
|
|
306
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: p.data, signature: p.signature }))) {
|
|
307
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
|
|
308
|
+
}
|
|
309
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
310
|
+
const inside = (record?.members || []).some((m) => m?.pub === pub)
|
|
311
|
+
audit('check', { device: await deviceIdOf(pub).catch(() => null), in: inside })
|
|
312
|
+
if (inside) return reply(from, { type: MSG.CHECKED, in: true })
|
|
313
|
+
await notifyIfRevoked(pub, null, null, 'revoked')
|
|
314
|
+
reply(from, { type: MSG.CHECKED, in: false })
|
|
315
|
+
}
|
|
316
|
+
|
|
276
317
|
async function handleDevices (from, p) {
|
|
277
318
|
if (!isFresh(p.data)) return staleReply(from)
|
|
278
319
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
@@ -280,7 +321,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
280
321
|
const { issued, revoked } = await identity.listDelegations()
|
|
281
322
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
282
323
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
283
|
-
const
|
|
324
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
284
325
|
// Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
|
|
285
326
|
// que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
|
|
286
327
|
// de tragarse un salto a ciegas. Si se salió de la ventana, llega vacía y toca
|
|
@@ -301,7 +342,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
301
342
|
const devices = await Promise.all(issued.map(async (x) => ({
|
|
302
343
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
303
344
|
})))
|
|
304
|
-
reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta, chain })
|
|
345
|
+
reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain })
|
|
305
346
|
}
|
|
306
347
|
|
|
307
348
|
// RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
|
|
@@ -322,10 +363,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
322
363
|
// `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
|
|
323
364
|
// QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
|
|
324
365
|
// Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
|
|
325
|
-
const
|
|
366
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
326
367
|
let scope = p.cert.scope
|
|
327
|
-
if (
|
|
328
|
-
scope = Acta.memberScopes(
|
|
368
|
+
if (record) {
|
|
369
|
+
scope = Acta.memberScopes(record, p.cert.sub)
|
|
329
370
|
if (!scope.length) {
|
|
330
371
|
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
|
|
331
372
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
@@ -338,7 +379,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
338
379
|
}
|
|
339
380
|
|
|
340
381
|
// SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
|
|
341
|
-
// pide el bundle de su namespace
|
|
382
|
+
// pide el bundle de su namespace — el del SCOPE (que comparten todos los
|
|
383
|
+
// aparatos que sirven ese ns) con el SUYO PROPIO encima (`secretsStore.js`).
|
|
384
|
+
// Lo suyo se indexa por la llave que firma esta misma petición, así que no hay
|
|
385
|
+
// manera de pedir lo de otro aparato. La respuesta va SELLADA a la llave ECDH
|
|
342
386
|
// efímera `ek` que vino en el sobre firmado (el proxy transporta pero no
|
|
343
387
|
// puede leer los valores) y el cuerpo va FIRMADO por la maestra (el
|
|
344
388
|
// servicio verifica contra su iss pineada — un relay no puede inyectar
|
|
@@ -357,14 +401,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
357
401
|
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
358
402
|
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
359
403
|
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
404
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
405
|
+
if (record && !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
362
406
|
audit('rejected', { what: 'secrets', ns, reason: 'cn' })
|
|
363
407
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
364
408
|
}
|
|
365
409
|
let enc
|
|
366
410
|
try {
|
|
367
|
-
enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns) } })
|
|
411
|
+
enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns, chk.device) } })
|
|
368
412
|
} catch (e) {
|
|
369
413
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
370
414
|
}
|
|
@@ -383,6 +427,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
383
427
|
if (payload.type === MSG.SIGN) return await handleSign(from, payload)
|
|
384
428
|
if (payload.type === MSG.GET) return await handleGet(from, payload)
|
|
385
429
|
if (payload.type === MSG.STORE) return await handleStore(from, payload)
|
|
430
|
+
if (payload.type === MSG.CHECK) return await handleCheck(from, payload)
|
|
386
431
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
387
432
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
388
433
|
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
@@ -408,14 +453,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
408
453
|
// AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
|
|
409
454
|
// escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
|
|
410
455
|
// reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
|
|
411
|
-
|
|
412
|
-
const
|
|
456
|
+
// La variable de entorno va en inglés (CONVENCIONES §8.1); nadie la tenía puesta.
|
|
457
|
+
const NOTICE_GROUP_MS = Number(process.env.DOTRINO_VAULT_NOTICE_MS) || 3000
|
|
458
|
+
const pendingNotices = new Map() // clave (ns | 'dev:'+pub) → timer
|
|
413
459
|
|
|
414
|
-
async function
|
|
415
|
-
let
|
|
460
|
+
async function notifyNsChange (ns) {
|
|
461
|
+
let targets = []
|
|
416
462
|
try {
|
|
417
463
|
const { issued } = await identity.listDelegations()
|
|
418
|
-
const
|
|
464
|
+
const revokedNonces = await revocationSet()
|
|
419
465
|
const scope = secretsScope(ns)
|
|
420
466
|
// Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
|
|
421
467
|
// que mandárselo a otro sería filtrarle que existe.
|
|
@@ -423,33 +469,62 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
423
469
|
// Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
|
|
424
470
|
// delegación nueva para la MISMA sub-clave, así que un agente que lleve
|
|
425
471
|
// tiempo enrolado aparece varias veces y recibiría el aviso repetido.
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
if (!x.sub ||
|
|
429
|
-
if (
|
|
430
|
-
|
|
472
|
+
const seen = new Set()
|
|
473
|
+
targets = (issued || []).filter((x) => {
|
|
474
|
+
if (!x.sub || revokedNonces.has(x.nonce) || !(x.scope || []).includes(scope)) return false
|
|
475
|
+
if (seen.has(x.sub)) return false
|
|
476
|
+
seen.add(x.sub)
|
|
431
477
|
return true
|
|
432
478
|
})
|
|
433
479
|
} catch (e) { return log('[vault] could not list who to notify:', e.message) }
|
|
434
|
-
if (!
|
|
480
|
+
if (!targets.length) return
|
|
435
481
|
|
|
436
482
|
const body = { op: 'secrets.changed', ns, ts: Date.now() }
|
|
437
483
|
const { signature } = await identity.signData(body)
|
|
438
|
-
for (const d of
|
|
484
|
+
for (const d of targets) {
|
|
439
485
|
try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
|
|
440
486
|
}
|
|
441
|
-
audit('secrets.changed', { ns,
|
|
442
|
-
log(`[vault] config for "${ns}" changed: notified ${
|
|
487
|
+
audit('secrets.changed', { ns, notified: targets.length })
|
|
488
|
+
log(`[vault] config for "${ns}" changed: notified ${targets.length} agent(s)`)
|
|
443
489
|
}
|
|
444
490
|
|
|
445
|
-
|
|
446
|
-
|
|
491
|
+
/**
|
|
492
|
+
* Cambió una variable de UN aparato: el aviso va solo a ese aparato. El mensaje dice
|
|
493
|
+
* qué NAMESPACE cambió (es lo que el agente sabe leer), así que hace falta su `cn`; un
|
|
494
|
+
* miembro sin `cn` no es un servicio y no lee variables, de modo que no hay a quién
|
|
495
|
+
* avisar y no se manda nada.
|
|
496
|
+
*/
|
|
497
|
+
async function notifyDeviceChange (pub) {
|
|
498
|
+
let cn = null
|
|
499
|
+
try {
|
|
500
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
501
|
+
cn = (record?.members || []).find((m) => m.pub === pub)?.cn || null
|
|
502
|
+
} catch (e) { return log('[vault] could not look up who to notify:', e.message) }
|
|
503
|
+
if (!cn) return
|
|
504
|
+
const body = { op: 'secrets.changed', ns: cn, ts: Date.now() }
|
|
505
|
+
const { signature } = await identity.signData(body)
|
|
506
|
+
try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
|
|
507
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
508
|
+
audit('secrets.changed', { ns: cn, device, notified: 1 })
|
|
509
|
+
log(`[vault] config for device ${device} ("${cn}") changed: notified it`)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Un cambio de configuración, un aviso: escrituras seguidas se agrupan en la misma
|
|
514
|
+
* ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
|
|
515
|
+
* lo de un aparato no cancele el aviso pendiente de todo su namespace.
|
|
516
|
+
*/
|
|
517
|
+
function scheduleNotice (ns) { schedule(ns, () => notifyNsChange(ns)) }
|
|
518
|
+
function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
|
|
519
|
+
|
|
520
|
+
function schedule (key, fn) {
|
|
521
|
+
clearTimeout(pendingNotices.get(key))
|
|
447
522
|
const t = setTimeout(() => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
},
|
|
523
|
+
pendingNotices.delete(key)
|
|
524
|
+
fn().catch((e) => log('[vault] change notice failed:', e.message))
|
|
525
|
+
}, NOTICE_GROUP_MS)
|
|
451
526
|
t.unref?.()
|
|
452
|
-
|
|
527
|
+
pendingNotices.set(key, t)
|
|
453
528
|
}
|
|
454
529
|
|
|
455
530
|
// --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
|
|
@@ -480,16 +555,76 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
480
555
|
// UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
|
|
481
556
|
// MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
|
|
482
557
|
// y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
|
|
483
|
-
// cuidado que en `
|
|
484
|
-
const
|
|
558
|
+
// cuidado que en `notifyNsChange`.
|
|
559
|
+
const seen = new Set()
|
|
485
560
|
for (const d of issued || []) {
|
|
486
|
-
if (!d.sub ||
|
|
487
|
-
|
|
561
|
+
if (!d.sub || seen.has(d.sub)) continue
|
|
562
|
+
seen.add(d.sub)
|
|
488
563
|
try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
|
|
489
564
|
}
|
|
490
565
|
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
491
566
|
}
|
|
492
567
|
|
|
568
|
+
/**
|
|
569
|
+
* MOSTRADOR DE VARIABLES para la consola remota (`lib/src/admin.js` enruta; la política
|
|
570
|
+
* y la cripto viven aquí, que es donde están la clave y el disco).
|
|
571
|
+
*
|
|
572
|
+
* Dos reglas, y son toda la frontera:
|
|
573
|
+
*
|
|
574
|
+
* 1. **El valor de una PRIVADA no sale de esta máquina.** Ni para un aparato tuyo con
|
|
575
|
+
* `admin`. Lo que viaja de una privada es su nombre y que es privada — con eso se
|
|
576
|
+
* le puede poner un valor nuevo a ciegas, que es lo que hace falta para rotarla.
|
|
577
|
+
* 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
|
|
578
|
+
* transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
|
|
579
|
+
*/
|
|
580
|
+
const varsDesk = {
|
|
581
|
+
async list () {
|
|
582
|
+
// `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
|
|
583
|
+
// la frontera se decide en un sitio, y lo mismo ve el dueño en su terminal que aquí.
|
|
584
|
+
// Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
|
|
585
|
+
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
586
|
+
return {
|
|
587
|
+
enc: await identity.sealContent(JSON.stringify({
|
|
588
|
+
ns: listSecrets(), dev: await listDeviceSecrets()
|
|
589
|
+
}))
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
async set ({ ns, pub, key, enc, public: isPublic }) {
|
|
593
|
+
const payload = JSON.parse(await identity.openContent(enc))
|
|
594
|
+
const value = payload?.value
|
|
595
|
+
if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
|
|
596
|
+
if (ns) setSecret(ns, key, value, isPublic)
|
|
597
|
+
else await setDeviceSecret(pub, key, value, isPublic)
|
|
598
|
+
return { ok: true, key }
|
|
599
|
+
},
|
|
600
|
+
/**
|
|
601
|
+
* VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
|
|
602
|
+
* al servicio de que su configuración cambió, y el servicio SALE para releerla entera
|
|
603
|
+
* (`watchEnv`). Guardadas de una en una, quien administra a distancia reiniciaba el
|
|
604
|
+
* servicio una vez por variable, y las primeras veces arrancaba con la configuración a
|
|
605
|
+
* medio poner. Juntas: un guardado, un aviso, un reinicio.
|
|
606
|
+
*
|
|
607
|
+
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
608
|
+
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
609
|
+
*/
|
|
610
|
+
async setMany ({ ns, pub, enc, public: isPublic }) {
|
|
611
|
+
const payload = JSON.parse(await identity.openContent(enc))
|
|
612
|
+
const items = payload?.items
|
|
613
|
+
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
|
|
614
|
+
// Borrar no se delega (`docs/consola-remota.md` §2): un aparato robado no puede
|
|
615
|
+
// dejar sin configuración a un servicio. Así que aquí solo entran valores nuevos.
|
|
616
|
+
/** @type {Array<{op:'set', key:string, value:string, public?:boolean}>} */
|
|
617
|
+
const list = items.map((it) => ({
|
|
618
|
+
op: /** @type {'set'} */ ('set'),
|
|
619
|
+
key: it?.key,
|
|
620
|
+
value: it?.value,
|
|
621
|
+
...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
|
|
622
|
+
}))
|
|
623
|
+
const keys = ns ? applySecrets(ns, list) : await applyDeviceSecrets(pub, list)
|
|
624
|
+
return { ok: true, keys }
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
493
628
|
const admin = createAdminDesk({
|
|
494
629
|
desk,
|
|
495
630
|
deviceIdOf,
|
|
@@ -497,6 +632,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
497
632
|
audit,
|
|
498
633
|
notify: notifyMembers,
|
|
499
634
|
readActivity,
|
|
635
|
+
vars: varsDesk,
|
|
500
636
|
// CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
|
|
501
637
|
// qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
|
|
502
638
|
// hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
|
|
@@ -513,8 +649,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
513
649
|
await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
|
|
514
650
|
return chk
|
|
515
651
|
}
|
|
516
|
-
const
|
|
517
|
-
if (
|
|
652
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
653
|
+
if (record && !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: 'acta' }
|
|
518
654
|
return chk
|
|
519
655
|
}
|
|
520
656
|
})
|
|
@@ -556,10 +692,176 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
556
692
|
reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
|
|
557
693
|
}
|
|
558
694
|
|
|
559
|
-
// API local de secretos (
|
|
560
|
-
|
|
561
|
-
function
|
|
562
|
-
function
|
|
695
|
+
// API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
|
|
696
|
+
// sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
|
|
697
|
+
function setSecret (ns, key, value, isPublic) { secrets.set(ns, key, value, isPublic); audit('secret.set', { ns, key }); scheduleNotice(ns) }
|
|
698
|
+
function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
702
|
+
*
|
|
703
|
+
* De una en una, cada `set` es un cambio de configuración para la bóveda, y el agente
|
|
704
|
+
* obedece el primero —sale, lo levanta el supervisor, lee lo que hubiera en ese
|
|
705
|
+
* instante— mientras el dueño sigue tecleando. El resultado es un servicio corriendo
|
|
706
|
+
* con media configuración, y encima con el arranque a medio hacer. La ventana de
|
|
707
|
+
* agrupado (`NOTICE_GROUP_MS`) tapa el caso de un script, no el de una persona
|
|
708
|
+
* escribiendo con quince segundos entre variable y variable.
|
|
709
|
+
*
|
|
710
|
+
* Así que la carga en grupo llega hasta aquí entera: se valida TODO primero, se
|
|
711
|
+
* escribe en un solo guardado y sale UN aviso al final. Las visibilidades no entran:
|
|
712
|
+
* no cambian lo que el servicio lee y por eso nunca avisaron.
|
|
713
|
+
*
|
|
714
|
+
* @param {string} ns
|
|
715
|
+
* @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
|
|
716
|
+
* @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
|
|
717
|
+
* estaba no cambia nada, y no tiene por qué reiniciar a nadie).
|
|
718
|
+
*/
|
|
719
|
+
function applySecrets (ns, items) {
|
|
720
|
+
const list = assertItems(items)
|
|
721
|
+
const changed = []
|
|
722
|
+
secrets.batch(() => {
|
|
723
|
+
for (const it of list) {
|
|
724
|
+
if (it.op === 'rm') {
|
|
725
|
+
if (secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
726
|
+
} else {
|
|
727
|
+
secrets.set(ns, it.key, it.value, it.public)
|
|
728
|
+
audit('secret.set', { ns, key: it.key })
|
|
729
|
+
changed.push(it.key)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
})
|
|
733
|
+
if (changed.length) scheduleNotice(ns)
|
|
734
|
+
return changed
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
|
|
738
|
+
async function applyDeviceSecrets (pub, items) {
|
|
739
|
+
const list = assertItems(items)
|
|
740
|
+
const m = await requireService(pub)
|
|
741
|
+
const changed = []
|
|
742
|
+
secrets.batch(() => {
|
|
743
|
+
for (const it of list) {
|
|
744
|
+
if (it.op === 'rm') {
|
|
745
|
+
if (secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
746
|
+
} else {
|
|
747
|
+
secrets.setDevice(pub, it.key, it.value, it.public)
|
|
748
|
+
changed.push(it.key)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
if (changed.length) {
|
|
753
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
754
|
+
for (const it of list) {
|
|
755
|
+
if (!changed.includes(it.key)) continue
|
|
756
|
+
audit(it.op === 'rm' ? 'secret.rm' : 'secret.set', { device, ns: m?.cn || null, key: it.key, scope: 'device' })
|
|
757
|
+
}
|
|
758
|
+
scheduleDeviceNotice(pub)
|
|
759
|
+
}
|
|
760
|
+
return changed
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Todo o nada: si una variable del grupo no vale, no se escribe NINGUNA. Media
|
|
765
|
+
* configuración cargada es peor que ninguna — el servicio arranca con ella.
|
|
766
|
+
*/
|
|
767
|
+
function assertItems (items) {
|
|
768
|
+
if (!Array.isArray(items) || !items.length) throw new Error('batch: no items')
|
|
769
|
+
for (const it of items) {
|
|
770
|
+
if (!it || (it.op !== 'set' && it.op !== 'rm')) throw new Error('batch: each item must be a set or an rm')
|
|
771
|
+
if (it.op === 'rm') { if (!it.key) throw new Error('batch: rm needs a key') } else assertVar(it.key, it.value)
|
|
772
|
+
}
|
|
773
|
+
return items
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Los nombres, y el VALOR de las públicas. Pública quiere decir «este valor puede salir
|
|
777
|
+
* de esta máquina»: taparlo justo aquí —en la máquina donde vive, delante de su dueño—
|
|
778
|
+
* era lo único que la marca no significaba. La consola remota ya las enseña.
|
|
779
|
+
*/
|
|
780
|
+
function listSecrets () {
|
|
781
|
+
const out = {}
|
|
782
|
+
for (const [ns, keys] of Object.entries(secrets.list())) {
|
|
783
|
+
const values = secrets.publicOf(ns)
|
|
784
|
+
out[ns] = keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k))
|
|
785
|
+
}
|
|
786
|
+
return out
|
|
787
|
+
}
|
|
788
|
+
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
789
|
+
function setSecretVisibility (ns, key, isPublic) {
|
|
790
|
+
const ok = secrets.setVisibility(ns, key, isPublic)
|
|
791
|
+
if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
|
|
792
|
+
return ok
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
|
|
796
|
+
async function memberOf (pub) {
|
|
797
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
798
|
+
if (!record) return null
|
|
799
|
+
return (record.members || []).find((m) => m.pub === pub) || null
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Variables de UN aparato. Se exige que sea un SERVICIO del acta (miembro con `cn`) porque
|
|
804
|
+
* es el único que las lee: guardárselas a un teléfono sería configuración muerta, escrita
|
|
805
|
+
* donde nadie la va a buscar el día que no funcione. Si la bóveda es anterior al acta no
|
|
806
|
+
* hay contra qué comprobarlo y se acepta.
|
|
807
|
+
*/
|
|
808
|
+
async function requireService (pub) {
|
|
809
|
+
const m = await memberOf(pub)
|
|
810
|
+
if (!m) {
|
|
811
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
812
|
+
if (record) throw new Error('device: it is not a member of this profile')
|
|
813
|
+
return null
|
|
814
|
+
}
|
|
815
|
+
if (!m.cn) throw new Error('device: it is not a service (only services read variables); pair it with `pair --service <ns>`')
|
|
816
|
+
return m
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function setDeviceSecret (pub, key, value, isPublic) {
|
|
820
|
+
const m = await requireService(pub)
|
|
821
|
+
secrets.setDevice(pub, key, value, isPublic)
|
|
822
|
+
audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
823
|
+
scheduleDeviceNotice(pub)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function deleteDeviceSecret (pub, key) {
|
|
827
|
+
const m = await memberOf(pub)
|
|
828
|
+
const ok = secrets.deleteDevice(pub, key)
|
|
829
|
+
if (ok) {
|
|
830
|
+
audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
831
|
+
scheduleDeviceNotice(pub)
|
|
832
|
+
}
|
|
833
|
+
return ok
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function setDeviceSecretVisibility (pub, key, isPublic) {
|
|
837
|
+
const ok = secrets.setDeviceVisibility(pub, key, isPublic)
|
|
838
|
+
if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
|
|
839
|
+
return ok
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Las variables por aparato —nombres, y el valor de las PÚBLICAS, igual que `listSecrets`—
|
|
844
|
+
* con quién es cada aparato pegado: una llave suelta no se puede administrar. `orphan`
|
|
845
|
+
* marca las que quedaron de una llave que ya no está en el acta.
|
|
846
|
+
*/
|
|
847
|
+
async function listDeviceSecrets () {
|
|
848
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
849
|
+
const members = record?.members || []
|
|
850
|
+
const out = []
|
|
851
|
+
for (const [pub, keys] of Object.entries(secrets.listDevices())) {
|
|
852
|
+
const m = members.find((x) => x.pub === pub) || null
|
|
853
|
+
const values = secrets.publicOfDevice(pub)
|
|
854
|
+
out.push({
|
|
855
|
+
pub,
|
|
856
|
+
id: m?.id || await deviceIdOf(pub).catch(() => null),
|
|
857
|
+
label: m?.label || '',
|
|
858
|
+
cn: m?.cn || null,
|
|
859
|
+
keys: keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k)),
|
|
860
|
+
orphan: !!members.length && !m
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
return out
|
|
864
|
+
}
|
|
563
865
|
|
|
564
866
|
return {
|
|
565
867
|
identity, client, store, threads, secrets, master, fingerprint: fp,
|
|
@@ -574,7 +876,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
574
876
|
return r
|
|
575
877
|
},
|
|
576
878
|
rejectDevice: (deviceId) => desk.reject(deviceId),
|
|
577
|
-
|
|
879
|
+
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
880
|
+
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
881
|
+
vars: varsDesk,
|
|
882
|
+
setSecret, deleteSecret, listSecrets, setSecretVisibility,
|
|
883
|
+
setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
|
|
884
|
+
applySecrets, applyDeviceSecrets,
|
|
578
885
|
listDevices: () => identity.listDelegations(),
|
|
579
886
|
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
580
887
|
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
@@ -613,8 +920,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
613
920
|
return r
|
|
614
921
|
},
|
|
615
922
|
close () {
|
|
616
|
-
for (const t of
|
|
617
|
-
|
|
923
|
+
for (const t of pendingNotices.values()) clearTimeout(t)
|
|
924
|
+
pendingNotices.clear()
|
|
618
925
|
try { client.close() } catch (_) {} identity.destroy()
|
|
619
926
|
}
|
|
620
927
|
}
|