@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/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 acta = (await identity.profileActa?.().catch(() => null))?.acta || null
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 acta = (await identity.profileActa?.().catch(() => null))?.acta
366
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
326
367
  let scope = p.cert.scope
327
- if (acta) {
328
- scope = Acta.memberScopes(acta, p.cert.sub)
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. La respuesta va SELLADA a la llave ECDH
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 acta = (await identity.profileActa?.().catch(() => null))?.acta
361
- if (acta && !Acta.memberCanReadSecrets(acta, chk.device, ns)) {
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
- const AVISO_AGRUPA_MS = Number(process.env.DOTRINO_VAULT_AVISO_MS) || 3000
412
- const avisosPendientes = new Map() // ns → timer
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 avisarCambio (ns) {
415
- let destinos = []
460
+ async function notifyNsChange (ns) {
461
+ let targets = []
416
462
  try {
417
463
  const { issued } = await identity.listDelegations()
418
- const revocados = await revocationSet()
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 vistas = new Set()
427
- destinos = (issued || []).filter((x) => {
428
- if (!x.sub || revocados.has(x.nonce) || !(x.scope || []).includes(scope)) return false
429
- if (vistas.has(x.sub)) return false
430
- vistas.add(x.sub)
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 (!destinos.length) return
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 destinos) {
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, avisados: destinos.length })
442
- log(`[vault] config for "${ns}" changed: notified ${destinos.length} agent(s)`)
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
- function programarAviso (ns) {
446
- clearTimeout(avisosPendientes.get(ns))
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
- avisosPendientes.delete(ns)
449
- avisarCambio(ns).catch((e) => log('[vault] change notice failed:', e.message))
450
- }, AVISO_AGRUPA_MS)
523
+ pendingNotices.delete(key)
524
+ fn().catch((e) => log('[vault] change notice failed:', e.message))
525
+ }, NOTICE_GROUP_MS)
451
526
  t.unref?.()
452
- avisosPendientes.set(ns, t)
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 `avisarCambio`.
484
- const vistas = new Set()
558
+ // cuidado que en `notifyNsChange`.
559
+ const seen = new Set()
485
560
  for (const d of issued || []) {
486
- if (!d.sub || vistas.has(d.sub)) continue
487
- vistas.add(d.sub)
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 acta = (await identity.profileActa?.().catch(() => null))?.acta
517
- if (acta && !Acta.memberCan(acta, chk.device, 'admin')) return { ok: false, reason: 'acta' }
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 (solo CLI/UI del dueño; audita cada cambio).
560
- function setSecret (ns, key, value) { secrets.set(ns, key, value); audit('secret.set', { ns, key }); programarAviso(ns) }
561
- function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); programarAviso(ns) } return ok }
562
- function listSecrets () { return secrets.list() }
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
- setSecret, deleteSecret, listSecrets,
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 avisosPendientes.values()) clearTimeout(t)
617
- avisosPendientes.clear()
923
+ for (const t of pendingNotices.values()) clearTimeout(t)
924
+ pendingNotices.clear()
618
925
  try { client.close() } catch (_) {} identity.destroy()
619
926
  }
620
927
  }