@dotrino/vaultd 0.38.0 → 0.46.2

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
@@ -22,6 +22,8 @@ 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
24
  import { openSecretsStore, assertVar } from './secretsStore.js'
25
+ import { makeSealer } from './sealer.js'
26
+ import { openSealKeys } from './sealKey.js'
25
27
  import { seal } from '../lib/src/sealed.js'
26
28
  import { dataDir, ensureDir } from './paths.js'
27
29
  import { atRestFor, machineKey, migrateFile } from './atrest.js'
@@ -37,7 +39,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
37
39
  * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
38
40
  * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
39
41
  */
40
- export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
42
+ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
41
43
  ensureDir(dir)
42
44
  // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
43
45
  // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
@@ -59,12 +61,60 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
59
61
  try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
60
62
  }
61
63
 
64
+ // LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
65
+ // abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
66
+ // y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
67
+ const sealKeys = openSealKeys(dir)
68
+ identity.setSealKeyProvider?.(() => sealKeys.mint())
69
+
62
70
  const store = openStore(dir)
63
71
  const threads = openThreadStore(dir)
64
- const secrets = openSecretsStore(dir)
72
+ const secrets = openSecretsStore(dir, {
73
+ sealer: makeSealer(),
74
+ // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
75
+ // propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
76
+ // acta, y por eso lo pone el vault: el store no conoce el acta.
77
+ recipients: (owner) => recipientsOf(owner),
78
+ // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
79
+ signer: (body) => signSeal(body),
80
+ defaultKey: () => new Uint8Array(machineKey(dir))
81
+ })
82
+ // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
83
+ // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
84
+ // con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
85
+ // siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
86
+ // Se dice en voz alta: prometer una protección que no está puesta es peor que no
87
+ // tenerla (docs/secretos-sellados.md §2.3).
88
+ try {
89
+ if (!hasPassword()) {
90
+ log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
91
+ log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
92
+ }
93
+ } catch (_) {}
94
+
65
95
  const master = await masterPubkeyOf(identity)
66
96
  const fp = (await pubkeyId(master)).slice(0, 16)
67
97
 
98
+ /**
99
+ * El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
100
+ * (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
101
+ * restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
102
+ * máquina y no puede quedar a medias.
103
+ *
104
+ * Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
105
+ * llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
106
+ */
107
+ try {
108
+ const info = await identity.profileActa?.()
109
+ const acta = info?.acta
110
+ if (info?.isMaster && acta && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
111
+ const r = await identity.rotateSealKey()
112
+ // Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
113
+ // primero y `console.log` no lo sustituye (salía «record #%s 2»).
114
+ log(`[vault] new sealing key in record #${r.seq}`)
115
+ }
116
+ } catch (e) { log('[vault] could not set up the sealing key:', e.message) }
117
+
68
118
  const { client } = await createTransport({ identity, dir, url: proxyUrl })
69
119
 
70
120
  async function revocationSet () {
@@ -138,6 +188,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
138
188
  const n = secrets.forgetDevice(sub)
139
189
  if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
140
190
  } catch (e) { log('[vault] could not drop the device variables:', e.message) }
191
+ // Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
192
+ // a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
193
+ // la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
194
+ //
195
+ // Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
196
+ // emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
197
+ // Un interruptor que pide una frase que quizá no tienes a mano no es un
198
+ // interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
199
+ markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
141
200
  },
142
201
  defaultScope: [SCOPE.READ],
143
202
  onChallenge ({ deviceId, scope }) {
@@ -388,8 +447,44 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
388
447
  // servicio verifica contra su iss pineada — un relay no puede inyectar
389
448
  // secretos falsos). Replay inerte: cada petición usa una ek nueva.
390
449
  // data: { op:'secrets', ns, ek, publickey, ts }
450
+ /**
451
+ * Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
452
+ * suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
453
+ * de variables va indexado por ella — se quedaría sin configuración sin decirlo.
454
+ */
455
+ async function handleEncKey (from, p) {
456
+ const ns = p.data?.ns
457
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
458
+ const chk = await verifyChain({
459
+ data: p.data, signature: p.signature, cert: p.cert,
460
+ expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
461
+ })
462
+ if (!chk.ok) return denyChain(from, chk, p, 'enckey')
463
+ try {
464
+ await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
465
+ audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
466
+ log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
467
+ // Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
468
+ // seguiría sin poder abrir nada hasta la siguiente escritura.
469
+ await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
470
+ const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
471
+ const { signature } = await identity.signData(body)
472
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
473
+ } catch (e) {
474
+ reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
475
+ }
476
+ }
477
+
391
478
  async function handleSecrets (from, p) {
392
479
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
480
+ // REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
481
+ // un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
482
+ // de identidad y obligaría a re-vendorizar.
483
+ //
484
+ // No exige la contraseña del perfil, y el argumento importa: registrar una llave no
485
+ // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
486
+ // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
487
+ if (p.data?.op === 'enckey') return handleEncKey(from, p)
393
488
  const ns = p.data?.ns
394
489
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
395
490
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -408,7 +503,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
408
503
  }
409
504
  let enc
410
505
  try {
411
- enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns, chk.device) } })
506
+ // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
507
+ // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
508
+ // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
509
+ // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
510
+ const b = secrets.bundleFor(ns, chk.device)
511
+ // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
512
+ // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
513
+ // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
514
+ // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
515
+ // de dónde salió lo que abre.
516
+ const payload = b.legacy
517
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
518
+ : { sealed: b, acta: record || null }
519
+ enc = await seal({ ek: p.data.ek, payload })
412
520
  } catch (e) {
413
521
  return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
414
522
  }
@@ -431,6 +539,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
431
539
  if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
432
540
  if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
433
541
  if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
542
+ if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
434
543
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
435
544
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
436
545
  } catch (e) {
@@ -577,6 +686,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
577
686
  * 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
578
687
  * transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
579
688
  */
689
+ /**
690
+ * La llave de administración a partir de lo que venga en el sobre de la consola
691
+ * remota. `undefined` si no trae contraseña — entonces el store cae a la de la
692
+ * máquina, que es lo correcto para un perfil que no tiene ninguna.
693
+ *
694
+ * La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
695
+ */
696
+ /**
697
+ * La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
698
+ * operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
699
+ */
700
+ async function adminKeyFrom (payload) {
701
+ const pwd = payload?.password
702
+ if (typeof pwd !== 'string' || !pwd) return undefined
703
+ if (typeof deriveAdminKey !== 'function') return undefined
704
+ return deriveAdminKey(pwd)
705
+ }
706
+
580
707
  const varsDesk = {
581
708
  async list () {
582
709
  // `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
@@ -585,16 +712,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
585
712
  // aprender cómo se llaman tus variables ni qué servicios corres.
586
713
  return {
587
714
  enc: await identity.sealContent(JSON.stringify({
588
- ns: listSecrets(), dev: await listDeviceSecrets()
715
+ ns: listSecrets(), dev: await listDeviceSecrets(),
716
+ // Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
717
+ // enseñarlo donde se administra, no solo en el log del servicio.
718
+ incomplete: await incompleteMembers(),
719
+ // Lo que quedó a deber un sellado: quien administra a distancia tiene que
720
+ // poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
721
+ // variable con la contraseña) y la bóveda no.
722
+ pending: await secretDebts(),
723
+ // Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
724
+ // llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
725
+ // El comentario de aquí decía «la consola lo dice en voz alta» y la consola
726
+ // no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
727
+ hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
589
728
  }))
590
729
  }
591
730
  },
592
- async set ({ ns, pub, key, enc, public: isPublic }) {
731
+ async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
593
732
  const payload = JSON.parse(await identity.openContent(enc))
594
733
  const value = payload?.value
595
734
  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)
735
+ // NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
736
+ // leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
737
+ if (ns) await setSecret(ns, key, value, isPublic, { by: who })
738
+ else await setDeviceSecret(pub, key, value, isPublic, { by: who })
598
739
  return { ok: true, key }
599
740
  },
600
741
  /**
@@ -607,7 +748,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
607
748
  * Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
608
749
  * transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
609
750
  */
610
- async setMany ({ ns, pub, enc, public: isPublic }) {
751
+ async setMany ({ ns, pub, enc, public: isPublic, by: who = null }) {
611
752
  const payload = JSON.parse(await identity.openContent(enc))
612
753
  const items = payload?.items
613
754
  if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
@@ -620,7 +761,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
620
761
  value: it?.value,
621
762
  ...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
622
763
  }))
623
- const keys = ns ? applySecrets(ns, list) : await applyDeviceSecrets(pub, list)
764
+ // NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` sin él la
765
+ // escritura quedaba al aire y la respuesta salía antes de guardar nada.
766
+ const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
624
767
  return { ok: true, keys }
625
768
  }
626
769
  }
@@ -694,8 +837,421 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
694
837
 
695
838
  // API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
696
839
  // 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 }
840
+ /**
841
+ * La llave con la que se abre la copia maestra.
842
+ *
843
+ * Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
844
+ * contraseña se cae a la llave de la máquina**, que es exactamente la protección
845
+ * que había antes de todo esto: el disco sigue cifrado, pero su material vive en
846
+ * ese mismo disco, así que una copia del disco lo abre.
847
+ *
848
+ * Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
849
+ * pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
850
+ * diseño). Prometer una protección que no está puesta es peor que no tenerla.
851
+ */
852
+ const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
853
+
854
+ // `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
855
+ // usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
856
+ // listar y borrar no la piden (ver `secretsStore.js`).
857
+ /**
858
+ * Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
859
+ * (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
860
+ * CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
861
+ * que este diseño quita.
862
+ */
863
+ async function nsMembers (ns) {
864
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
865
+ return (record?.members || []).filter((m) => m.cn === ns)
866
+ }
867
+
868
+ /**
869
+ * Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
870
+ * cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
871
+ * ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
872
+ * frase que se escribe en cualquier parte a una llave que no sale del aparato.
873
+ *
874
+ * La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
875
+ * ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
876
+ * diseño quita. Hay un test que lo afirma.
877
+ */
878
+ async function adminDevices () {
879
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
880
+ return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
881
+ }
882
+
883
+ /**
884
+ * Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
885
+ * importa (decidida por el dueño el 2026-08-22):
886
+ *
887
+ * **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
888
+ * hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
889
+ * token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
890
+ * alguien se lleve tu portátil con la sesión puesta.
891
+ *
892
+ * Dos cosas que hacen que esto se sostenga:
893
+ *
894
+ * · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
895
+ * que la bóveda se negara a entregarlo, pero entonces la protección sería una
896
+ * política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
897
+ * llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
898
+ * se crea no se puede saltar.
899
+ * · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
900
+ * dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
901
+ * se puede forzar esperando a que ese servicio esté caído.
902
+ *
903
+ * Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
904
+ * miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
905
+ * abre la envoltura de recuperación, que `wrapAll` añade siempre.
906
+ */
907
+ /**
908
+ * Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
909
+ * el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
910
+ * quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
911
+ * qué pidió; aquí solo se valida el remitente.
912
+ */
913
+ async function handleRewrapOk (payload) {
914
+ const d = payload?.data
915
+ if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
916
+ const signer = payload?.cert?.sub
917
+ if (!signer) return
918
+ if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
919
+ return log('[vault] a handed key arrived BADLY SIGNED: ignored')
920
+ }
921
+ // Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
922
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
923
+ const m = (record?.members || []).find((x) => x.pub === signer)
924
+ const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
925
+ if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
926
+ for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
927
+ }
928
+
929
+ /** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
930
+ const rewrapWaiters = new Set()
931
+ const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
932
+
933
+ async function recipientsOf (owner) {
934
+ if (owner.startsWith('ns:')) {
935
+ const owned = await nsMembers(owner.slice(3))
936
+ // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
937
+ // administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
938
+ return owned.length ? owned : await adminDevices()
939
+ }
940
+ const pub = owner.slice(owner.indexOf(':') + 1)
941
+ const m = await memberOf(pub)
942
+ // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
943
+ if (m?.cn) return [m]
944
+ return [...(m ? [m] : []), ...await adminDevices()]
945
+ }
946
+
947
+ /**
948
+ * Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
949
+ * del acta junto a la firma: es lo que le dice a quien verifica con qué llave
950
+ * comprobarla, porque esa llave rota con el acta (§8.9).
951
+ *
952
+ * Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
953
+ * es más importante que poder demostrar después de dónde salió.
954
+ */
955
+ async function signSeal (body) {
956
+ try {
957
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
958
+ if (!acta?.sealPub) return null
959
+ const sig = await sealKeys.sign(acta.sealPub, body)
960
+ return sig ? { seq: acta.seq, sig } : null
961
+ } catch (_) { return null }
962
+ }
963
+
964
+ /**
965
+ * Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
966
+ * del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
967
+ * abrir y se queda reintentando para siempre, sin decir por qué.
968
+ *
969
+ * No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
970
+ * reiniciaría a todos los nodos del ns para nada.
971
+ */
972
+ /**
973
+ * Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
974
+ * el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
975
+ * consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
976
+ *
977
+ * Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
978
+ * que ya no está podría abrir lo que se escriba mañana.
979
+ */
980
+ async function markRotationDue (sub) {
981
+ const m = await memberOf(sub)
982
+ const ns = m?.cn
983
+ if (!ns) return
984
+ try {
985
+ const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
986
+ if (r?.rotated != null) {
987
+ log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
988
+ audit('secret.rotate', { ns, keys: r.rotated })
989
+ scheduleNotice(ns)
990
+ return
991
+ }
992
+ } catch (e) {
993
+ store.setSetting(`rotate-due:${ns}`, String(Date.now()))
994
+ log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
995
+ audit('secret.rotate-due', { ns, reason: e.message })
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
1001
+ * deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
1002
+ * salen juntas:
1003
+ *
1004
+ * - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
1005
+ * - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
1006
+ *
1007
+ * Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
1008
+ * enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
1009
+ * que además nadie ve.
1010
+ */
1011
+ /**
1012
+ * Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
1013
+ * las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
1014
+ * llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
1015
+ * hay quien la teclee.
1016
+ *
1017
+ * Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
1018
+ * vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
1019
+ * otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
1020
+ * mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
1021
+ * (`dotrino-vault secret settle`, que sí pide la frase).
1022
+ *
1023
+ * @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
1024
+ */
1025
+ async function incompleteMembers () {
1026
+ if (secrets.isLegacy?.()) return []
1027
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1028
+ const out = []
1029
+ for (const m of record?.members || []) {
1030
+ if (!m.encPub || m.pub === master) continue
1031
+ const owners = {}
1032
+ // Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
1033
+ // puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
1034
+ // «Ver» de la consola le fallaría sin explicar por qué.
1035
+ // Un aparato que administra NO debe tener envoltura de los cajones con dueño
1036
+ // (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
1037
+ // propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
1038
+ const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
1039
+ const drawers = m.cn
1040
+ ? [`ns:${m.cn}`, `dev:${m.pub}`]
1041
+ : [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
1042
+ for (const owner of drawers) {
1043
+ const missing = secrets.missingFor(owner, m.pub)
1044
+ if (missing.length) owners[owner] = missing
1045
+ }
1046
+ if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
1047
+ }
1048
+ return out
1049
+ }
1050
+
1051
+ /**
1052
+ * Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
1053
+ * no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
1054
+ * estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
1055
+ */
1056
+ function rotationsDue () {
1057
+ const out = {}
1058
+ for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
1059
+ // La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
1060
+ // nombre del namespace: una sola forma para quien lo lee.
1061
+ if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
1062
+ }
1063
+ return out
1064
+ }
1065
+
1066
+ /**
1067
+ * TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
1068
+ * envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
1069
+ * se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
1070
+ * se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
1071
+ * entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
1072
+ * @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
1073
+ */
1074
+ async function secretDebts () {
1075
+ const out = rotationsDue()
1076
+ for (const m of await incompleteMembers()) {
1077
+ for (const [owner, keys] of Object.entries(m.owners)) {
1078
+ if (out[owner]?.kind === 'rotate') continue
1079
+ out[owner] = out[owner] || { kind: 'rewrap', members: [] }
1080
+ out[owner].members.push({ pub: m.pub, keys })
1081
+ }
1082
+ }
1083
+ return out
1084
+ }
1085
+
1086
+ async function spreadKey (owner, members, adminKey) {
1087
+ if (secrets.isLegacy()) return null
1088
+ // Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
1089
+ // se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
1090
+ // sustituye al reparto en vez de sumarse.
1091
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1092
+ if (ns && store.getSetting(`rotate-due:${ns}`)) {
1093
+ const rot = await secrets.rotate(owner, members, adminKey)
1094
+ store.setSetting(`rotate-due:${ns}`, undefined)
1095
+ log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
1096
+ audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
1097
+ return rot
1098
+ }
1099
+ try {
1100
+ const r = await secrets.rewrap(owner, members, adminKey)
1101
+ if (r?.sinLlave?.length) {
1102
+ log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
1103
+ audit('secret.nokey', { owner, count: r.sinLlave.length })
1104
+ }
1105
+ return r
1106
+ } catch (e) {
1107
+ // SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
1108
+ // donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
1109
+ // cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
1110
+ // del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
1111
+ // — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
1112
+ // anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
1113
+ // salde un hermano o el abrir la bóveda.
1114
+ log(`[vault] ${owner}: could not hand out its key (${e.message}); its members will not read their variables until a sibling hands it out or the vault is opened`)
1115
+ audit('secret.rewrap-due', { owner, reason: e.message })
1116
+ throw e
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
1122
+ * a leer. `by` es el aparato que lo escribió, y va al histórico.
1123
+ */
1124
+ async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
1125
+ await secrets.set(ns, key, value, isPublic, { by })
1126
+ await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1127
+ audit('secret.set', { ns, key }); scheduleNotice(ns)
1128
+ }
1129
+
1130
+ /**
1131
+ * SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
1132
+ * desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
1133
+ * verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
1134
+ * único que la frase guarda (§8.3).
1135
+ *
1136
+ * No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
1137
+ * tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
1138
+ */
1139
+ /**
1140
+ * PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
1141
+ *
1142
+ * Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
1143
+ * llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
1144
+ * tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
1145
+ *
1146
+ * La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
1147
+ * podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
1148
+ * destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
1149
+ * solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
1150
+ *
1151
+ * @returns {Promise<{ done: number, asked: number }>}
1152
+ */
1153
+ async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
1154
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1155
+ if (!ns) return { done: 0, asked: 0 }
1156
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1157
+ if (!record) return { done: 0, asked: 0 }
1158
+ // Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
1159
+ const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
1160
+ const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
1161
+ if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
1162
+
1163
+ let done = 0
1164
+ for (const gen of pending) {
1165
+ const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
1166
+ const { signature } = await identity.signData(body)
1167
+ const answer = new Promise((resolve) => {
1168
+ const off = onRewrapOk((d) => {
1169
+ if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
1170
+ off(); resolve(d.wrap)
1171
+ })
1172
+ setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
1173
+ })
1174
+ try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, signature }) } catch (e) {
1175
+ log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
1176
+ continue
1177
+ }
1178
+ const wrap = await answer
1179
+ if (!wrap) continue
1180
+ try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
1181
+ log(`[vault] ${owner}: the handed key was refused (${e.message})`)
1182
+ }
1183
+ }
1184
+ if (done) {
1185
+ audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
1186
+ log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
1187
+ }
1188
+ return { done, asked: pending.length }
1189
+ }
1190
+
1191
+ async function resealAll (adminKey = null) {
1192
+ const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
1193
+ for (const owner of secrets.owners?.() || []) {
1194
+ const before = new Set(secrets.recipientsIn(owner))
1195
+ const members = await recipientsOf(owner)
1196
+ try {
1197
+ const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
1198
+ const after = new Set(secrets.recipientsIn(owner))
1199
+ out.drawers++
1200
+ out.wrapped += r?.wrapped || 0
1201
+ out.dropped += [...before].filter((p) => !after.has(p)).length
1202
+ for (const p of r?.sinLlave || []) out.sinLlave.push(p)
1203
+ } catch (e) {
1204
+ log(`[vault] ${owner}: could not reseal (${e.message})`)
1205
+ }
1206
+ }
1207
+ if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
1208
+ return out
1209
+ }
1210
+
1211
+ async function settleSecretDebts (adminKey = null) {
1212
+ const out = {}
1213
+ // Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
1214
+ // envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
1215
+ // por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
1216
+ // pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
1217
+ // que es barato y no depende de que alguien se acordara de apuntarlo.
1218
+ const debts = new Set(Object.keys(rotationsDue()))
1219
+ for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
1220
+
1221
+ for (const owner of debts) {
1222
+ const k = owner.slice(owner.indexOf(':') + 1)
1223
+ // Los destinatarios de un cajón no son solo quien lo consume: también quien lo
1224
+ // administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
1225
+ // sin poder destapar lo que ella misma acababa de saldar.
1226
+ const members = await recipientsOf(owner)
1227
+ try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
1228
+ // Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
1229
+ let delegated = 0
1230
+ for (const m of await incompleteMembers()) {
1231
+ if (!m.owners[owner]) continue
1232
+ delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
1233
+ }
1234
+ out[owner] = delegated ? { delegated } : { error: e.message }
1235
+ }
1236
+ }
1237
+ return out
1238
+ }
1239
+
1240
+ /**
1241
+ * Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
1242
+ * Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
1243
+ * recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
1244
+ * anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
1245
+ * guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
1246
+ */
1247
+ async function settleDebts (owner, membersFn) {
1248
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1249
+ const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
1250
+ (await incompleteMembers()).some((m) => m.owners[owner])
1251
+ if (!owed) return null
1252
+ try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
1253
+ }
1254
+ async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
699
1255
 
700
1256
  /**
701
1257
  * CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
@@ -716,40 +1272,44 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
716
1272
  * @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
717
1273
  * estaba no cambia nada, y no tiene por qué reiniciar a nadie).
718
1274
  */
719
- function applySecrets (ns, items) {
1275
+ async function applySecrets (ns, items, { by = null } = {}) {
720
1276
  const list = assertItems(items)
721
1277
  const changed = []
722
- secrets.batch(() => {
1278
+ await secrets.batch(async () => {
723
1279
  for (const it of list) {
724
1280
  if (it.op === 'rm') {
725
- if (secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
1281
+ if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
726
1282
  } else {
727
- secrets.set(ns, it.key, it.value, it.public)
1283
+ await secrets.set(ns, it.key, it.value, it.public, { by })
728
1284
  audit('secret.set', { ns, key: it.key })
729
1285
  changed.push(it.key)
730
1286
  }
731
1287
  }
732
1288
  })
733
- if (changed.length) scheduleNotice(ns)
1289
+ if (changed.length) {
1290
+ await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1291
+ scheduleNotice(ns)
1292
+ }
734
1293
  return changed
735
1294
  }
736
1295
 
737
1296
  /** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
738
- async function applyDeviceSecrets (pub, items) {
1297
+ async function applyDeviceSecrets (pub, items, { by = null } = {}) {
739
1298
  const list = assertItems(items)
740
1299
  const m = await requireService(pub)
741
1300
  const changed = []
742
- secrets.batch(() => {
1301
+ await secrets.batch(async () => {
743
1302
  for (const it of list) {
744
1303
  if (it.op === 'rm') {
745
- if (secrets.deleteDevice(pub, it.key)) changed.push(it.key)
1304
+ if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
746
1305
  } else {
747
- secrets.setDevice(pub, it.key, it.value, it.public)
1306
+ await secrets.setDevice(pub, it.key, it.value, it.public, { by })
748
1307
  changed.push(it.key)
749
1308
  }
750
1309
  }
751
1310
  })
752
1311
  if (changed.length) {
1312
+ await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
753
1313
  const device = await deviceIdOf(pub).catch(() => null)
754
1314
  for (const it of list) {
755
1315
  if (!changed.includes(it.key)) continue
@@ -786,12 +1346,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
786
1346
  return out
787
1347
  }
788
1348
  /** 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)
1349
+ async function setSecretVisibility (ns, key, isPublic, adminKey) {
1350
+ const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
791
1351
  if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
792
1352
  return ok
793
1353
  }
794
1354
 
1355
+ /**
1356
+ * El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
1357
+ * camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
1358
+ * este sí pide poder abrir la copia maestra.
1359
+ */
1360
+ async function openSecrets (ns, devicePub = null, adminKey) {
1361
+ return secrets.openBundle(ns, devicePub, adminKey)
1362
+ }
1363
+
795
1364
  /** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
796
1365
  async function memberOf (pub) {
797
1366
  const record = (await identity.profileActa?.().catch(() => null))?.acta
@@ -816,16 +1385,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
816
1385
  return m
817
1386
  }
818
1387
 
819
- async function setDeviceSecret (pub, key, value, isPublic) {
1388
+ async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
820
1389
  const m = await requireService(pub)
821
- secrets.setDevice(pub, key, value, isPublic)
1390
+ await secrets.setDevice(pub, key, value, isPublic, { by })
1391
+ await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
822
1392
  audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
823
1393
  scheduleDeviceNotice(pub)
824
1394
  }
825
1395
 
826
1396
  async function deleteDeviceSecret (pub, key) {
827
1397
  const m = await memberOf(pub)
828
- const ok = secrets.deleteDevice(pub, key)
1398
+ const ok = await secrets.deleteDevice(pub, key)
829
1399
  if (ok) {
830
1400
  audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
831
1401
  scheduleDeviceNotice(pub)
@@ -833,8 +1403,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
833
1403
  return ok
834
1404
  }
835
1405
 
836
- async function setDeviceSecretVisibility (pub, key, isPublic) {
837
- const ok = secrets.setDeviceVisibility(pub, key, isPublic)
1406
+ async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
1407
+ const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
838
1408
  if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
839
1409
  return ok
840
1410
  }
@@ -864,14 +1434,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
864
1434
  }
865
1435
 
866
1436
  return {
867
- identity, client, store, threads, secrets, master, fingerprint: fp,
1437
+ identity, client, store, threads, secrets, master, fingerprint: fp, dir,
868
1438
  startPairing: desk.startPairing,
869
1439
  stopPairing: desk.stopPairing,
870
1440
  listPending: desk.listPending,
871
1441
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
872
1442
  // dispositivos se entera de que entró alguien, venga de donde venga.
873
- approveDevice: async (code) => {
1443
+ approveDevice: async (code, adminKey) => {
874
1444
  const r = await desk.approve(code)
1445
+ // Un servicio que ENTRA a un namespace que ya tiene variables necesita su
1446
+ // envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
1447
+ // reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
1448
+ // puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
1449
+ // el archivo vendorizado en el iframe de identidad.
1450
+ const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
1451
+ if (m?.cn) {
1452
+ await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
1453
+ log('[vault] could not hand the key to the new service:', e.message)
1454
+ // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
1455
+ // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
1456
+ // recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
1457
+ // a la vista y se salda al abrir la bóveda.
1458
+ const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
1459
+ if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
1460
+ })
1461
+ }
875
1462
  await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
876
1463
  return r
877
1464
  },
@@ -879,13 +1466,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
879
1466
  // El mostrador que atiende a la consola remota. Se expone para poder probar la
880
1467
  // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
881
1468
  vars: varsDesk,
882
- setSecret, deleteSecret, listSecrets, setSecretVisibility,
1469
+ setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
1470
+ // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
1471
+ // por eso es una operación con nombre propio, no algo que ocurra de refilón al
1472
+ // desbloquear: deja `secrets.json.v3.bak` para poder volver.
1473
+ /**
1474
+ * Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
1475
+ * normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
1476
+ * cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
1477
+ *
1478
+ * Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
1479
+ * entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
1480
+ * diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
1481
+ */
1482
+ resealAll,
1483
+ delegateRewrap,
1484
+ incompleteMembers,
1485
+ migrateSecrets: (membersOf, adminKey) => secrets.migrate(
1486
+ membersOf || ((owner) => recipientsOf(owner)), adminKey
1487
+ ),
1488
+ settleSecretDebts,
1489
+ revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
1490
+ secretHistory: (owner, key) => secrets.history(owner, key),
1491
+ revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
1492
+ revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
1493
+ // Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
1494
+ // la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
1495
+ rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
883
1496
  setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
884
1497
  applySecrets, applyDeviceSecrets,
885
1498
  listDevices: () => identity.listDelegations(),
886
1499
  // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
887
1500
  // `dotrino-vault members` y la consola de vault.dotrino.com.
888
1501
  profileMembers: () => identity.profileMembers(),
1502
+ // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
1503
+ // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
1504
+ rotationsDue,
1505
+ secretDebts,
889
1506
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
890
1507
  isMaster: () => identity.isMaster(),
891
1508
  setCaps: async (pub, caps) => {