@dotrino/vaultd 0.155.0 → 0.156.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.
@@ -0,0 +1,4414 @@
1
+ /**
2
+ * dotrino-vault — núcleo del certificador personal (daemon headless).
3
+ *
4
+ * Custodia la clave MAESTRA del usuario (vía `@dotrino/identity`) y la expone como
5
+ * CA propia. EMPAREJAMIENTO ENDURECIDO (ver docs/pairing-protocol.md): el token de
6
+ * 5 min ya NO es autoridad suficiente — para obtener un cert el dispositivo debe
7
+ * (1) PROBAR posesión de su llave D firmando el ENROLL, y (2) el dueño debe APROBAR
8
+ * en el PC tras comparar un SAS (código de 6 dígitos) entre las dos pantallas. La
9
+ * maestra solo firma el cert DESPUÉS de esa aprobación humana.
10
+ *
11
+ * Toda la cripto es de `@dotrino/identity`. Este módulo solo orquesta.
12
+ */
13
+ import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
14
+ import { createLoginDesk, registerLogin, vaultChannel, accountFingerprint } from '../passwordLogins.js'
15
+ import * as ActaPilar from '@dotrino/identity/acta'
16
+ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, scopeToCaps, scopeToCn } from '../enroll.js'
17
+ import { createAdminDesk, authorBody } from '../admin.js'
18
+ import { shouldNotifyRevoked } from '../revocation.js'
19
+ // El sobre del gestor, el mismo que usan la bóveda-en-pestaña y la extensión.
20
+ import { identitySealing } from '@dotrino/passmanager'
21
+ import { createTransport, masterPubkeyOf } from './transport.js'
22
+ import { startSealersPublisher } from './sealers.js'
23
+ import { openStore } from './store.js'
24
+ import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
25
+ import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
26
+ import { openSubacta } from './subacta.js'
27
+ import { openBlocked } from './blocked.js'
28
+ import { openNotifiable } from './notifiable.js'
29
+ import { openMachines } from './machines.js'
30
+ import { machineLine } from '@dotrino/identity/machine'
31
+ import { openNotices } from './notices.js'
32
+ import { makeEphemeralKey, openSealed } from '../sealed.js'
33
+ import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
34
+ import { loadManifest, brokenOf } from '@dotrino/roadmap'
35
+ // `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
36
+ // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
37
+ // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
38
+ // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
39
+ import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS, UPDATE_TTL_MS } from './approvals.js'
40
+ import { sanitizeContext, commandFingerprint, commandLine } from '../procContext.js'
41
+ import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
42
+ import { makeSealer } from './sealer.js'
43
+ import { openSealKeys } from './sealKey.js'
44
+ import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
45
+ import { seal } from '../sealed.js'
46
+ import { MSG, SCOPE, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from '../protocol.js'
47
+ import { sha256Hex } from './sha256.js'
48
+
49
+ const b64ToBytes = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
50
+ const byteLength = (s) => new TextEncoder().encode(s).length
51
+
52
+ /**
53
+ * Tope de una respuesta de la bóveda por el transporte. El proxio (`PROXY_MAX_FRAME_BYTES`)
54
+ * corta el frame a 1 MB, y el sobre del proxio (destinatarios, tipos) va por encima de esto:
55
+ * se deja margen en vez de apurar el límite.
56
+ */
57
+ const MAX_REPLY_BYTES = 768 * 1024
58
+
59
+ /**
60
+ * Abre UN perfil del vault (una maestra, un dir, una conexión al proxy). El
61
+ * daemon multi-perfil (`manager.js`) levanta uno de estos por perfil.
62
+ *
63
+ * @param {Object} [opts]
64
+ * @param {string} [opts.dir] Dir de datos de ESTE perfil.
65
+ * @param {() => boolean} [opts.isLocked] Candado del perfil (contraseña opcional).
66
+ * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
67
+ * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
68
+ */
69
+ /**
70
+ * LA BÓVEDA, la misma en las tres versiones (binario, pestaña y extensión).
71
+ *
72
+ * Todo lo que depende de dónde corre llega en `platform` (ver `src/platform.js` para el
73
+ * binario y `./browserPlatform.js` para el navegador):
74
+ *
75
+ * disk el disco del perfil (`./disk.js`)
76
+ * openIdentity({ openKey, log }) → la identidad del perfil, ya abierta
77
+ * product, version con qué se anuncia (`@dotrino/compat`)
78
+ * hostname() el nombre de esta máquina, o `null` si no se sabe
79
+ * readProcContext(pid) lo que el sistema dice de un proceso; `null` si no se puede mirar
80
+ * openLocalDesk({ name, onMessage, channels, maxBytes, log }) el atajo en la misma máquina
81
+ * makeClient quién crea el cliente del proxio (opcional)
82
+ * noticeGroupMs cuánto se agrupan los avisos de cambio
83
+ * opaque el servidor de OPAQUE, si no vale el del paquete (opcional)
84
+ * servePasswords `false`: el almacén de contraseñas lo atiende quien monta la bóveda,
85
+ * con la política de aquí (`passwordsNeedApproval`/`approvePasswordRequest`)
86
+ *
87
+ * Una plataforma que no puede algo (el navegador no tiene `/proc` ni sockets de archivo)
88
+ * no lo pone, y el núcleo lo trata como «no se sabe»: nunca como un sí.
89
+ */
90
+ export async function startVault ({ platform, proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, openKey = null, secondaryParams = null, openWithSecondary = null, hasSecondary = () => false, forAdoption = false, onAdopted } = {}) {
91
+ if (!platform?.disk || typeof platform.openIdentity !== 'function') {
92
+ throw Object.assign(new Error('startVault: a platform with a disk and openIdentity is required'), { code: 'no-platform' })
93
+ }
94
+ const disk = platform.disk
95
+ const VERSION = platform.version
96
+ if (typeof VERSION !== 'string' || !VERSION) throw Object.assign(new Error('startVault: the platform must say which version this is'), { code: 'no-version' })
97
+ // La identidad del perfil la abre la plataforma: en el binario sale de su directorio
98
+ // (cifrada en reposo y con la maestra sellada bajo la contraseña), en el navegador es la
99
+ // que ya tiene abierta la pestaña o la extensión.
100
+ const identity = await platform.openIdentity({ openKey, log })
101
+ if (!identity.me?.publickey) await identity.setMyNickname('')
102
+ // CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
103
+ // igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
104
+ // acepte cambiar su acta recién nacida por la que traiga el dispositivo. Sin la marca,
105
+ // adoptar sería pisar una cuenta con datos y se rechaza — que es lo correcto por defecto.
106
+ if (forAdoption) {
107
+ try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
108
+ }
109
+
110
+ // LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
111
+ // abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
112
+ // y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
113
+ const sealKeys = openSealKeys(disk)
114
+ const commKey = openCommKey(disk)
115
+ identity.setSealKeyProvider?.(() => sealKeys.mint())
116
+
117
+ const store = openStore(disk)
118
+ const threads = openThreadStore(disk)
119
+ /**
120
+ * LO QUE SIRVE LA BÓVEDA VA CON LA LLAVE VIGENTE (dueño, 2026-09-30). Tras una rotación
121
+ * de la llave de la cuenta, los sobres (`t: 'dotrino-cek'`) de generaciones anteriores
122
+ * que haya en el almacén se vuelven a cerrar con la vigente. Se llama al abrir y después
123
+ * de quitar un aparato. Si la bóveda no tiene la llave de contenido, no hay nada que
124
+ * hacer y se dice; si un sobre no abre, se para y se dice — no se deja a medias.
125
+ */
126
+ async function resealStaleThreads (why) {
127
+ const mine = await identity.contentKey?.()
128
+ if (!mine) { log(`[vault] ${why}: no content key on this vault, envelopes not resealed`); return null }
129
+ const r = await threads.resealStaleEnvelopes({
130
+ gen: mine.gen,
131
+ reseal: async (env) => identity.sealContent(await identity.openContent(env))
132
+ })
133
+ if (r.changed) {
134
+ audit('resealed', { envelopes: r.changed, entries: r.entries, gen: mine.gen })
135
+ log(`[vault] ${why}: resealed ${r.changed} envelope(s) in ${r.entries} entr${r.entries === 1 ? 'y' : 'ies'} with content key generation ${mine.gen}`)
136
+ }
137
+ if (r.skipped) log(`[vault] ${why}: ${r.skipped} unmarked envelope(s) that the account key does not open were left as they are`)
138
+ return r
139
+ }
140
+ const approvals = createApprovals()
141
+ /**
142
+ * LO QUE YA SE APROBÓ: comando + carpeta, por una hora desde el último uso.
143
+ *
144
+ * En memoria a propósito. Reiniciar la bóveda las borra todas, que es lo correcto: un
145
+ * permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado.
146
+ */
147
+ const grants = createGrants()
148
+
149
+ /**
150
+ * Pedidos de aprobación que NO son de un cajón de secretos: quien espera es una
151
+ * promesa dentro del vault (la bóveda de contraseñas), no un aparato aguardando un
152
+ * sobre sellado. `id` del pedido → `resolve(boolean)`.
153
+ *
154
+ * Aparte a propósito: `approvals` es un módulo puro y no tiene por qué saber que hay
155
+ * dos clases de espera.
156
+ */
157
+ const waiters = new Map()
158
+ /**
159
+ * ¿ESTE APARATO TIENE QUE PEDIR PERMISO PARA RECIBIR CLAVES PRIVADAS?
160
+ *
161
+ * Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
162
+ * no, espera a que un aparato con `approve` lo firme.
163
+ *
164
+ * Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
165
+ * un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
166
+ * omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
167
+ * el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
168
+ * 2026-09-01).
169
+ *
170
+ * Y al estar en el acta lo respeta cualquier bóveda de la cuenta, se ve en la pantalla de
171
+ * permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
172
+ * en una máquina.
173
+ */
174
+ /** ¿Hay en el acta alguien que pueda aprobar? Sin nadie, no se pide aprobación. */
175
+ function hasApprover (record) {
176
+ return !!record?.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))
177
+ }
178
+
179
+ function needsApproval (pub, record) {
180
+ if (!record) return true // sin acta no se decide que sí: se pide permiso
181
+ // SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
182
+ // no hay aprobador no se necesita aprobación»). Es la misma que ya seguía la
183
+ // actualización automática: esperar un sí que nadie puede dar solo deja al servicio
184
+ // colgado. Aquí se decide para las tres puertas —entregar claves, contraseñas y guardar
185
+ // variables— y por eso no hay un «nadie aprueba» aparte en ninguna de ellas.
186
+ if (!record.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))) return false
187
+ return !Acta.memberCan(record, pub, 'unattended')
188
+ }
189
+
190
+ /**
191
+ * APUNTA UN PEDIDO QUE NO ENTREGA UN CAJÓN, avisa a quien aprueba y deja la respuesta en
192
+ * `onAnswer(sí|no)`. Lo usan la bóveda de contraseñas (un aparato espera su sí) y guardar
193
+ * variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
194
+ * nada se queda colgado para siempre.
195
+ */
196
+ async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer, ttlMs = PENDING_TTL_MS }) {
197
+ const deviceId = await deviceIdOf(device).catch(() => null)
198
+ const record = await refreshActa()
199
+ const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
200
+ const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind, ttlMs })
201
+ audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
202
+ log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
203
+ waiters.set(pend.id, onAnswer)
204
+ const t = setTimeout(() => {
205
+ if (waiters.delete(pend.id)) {
206
+ audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
207
+ onAnswer(false)
208
+ }
209
+ }, ttlMs)
210
+ t.unref?.()
211
+ await notifyApprovers(pend, record)
212
+ return pend
213
+ }
214
+
215
+ /**
216
+ * QUÉ COMANDO ESTÁ PIDIENDO LAS CLAVES, Y DESDE QUÉ CARPETA — medido, no creído.
217
+ *
218
+ * Quien aprueba no puede decidir con «el aparato 904C-1002 pide el cajón proxy»: necesita
219
+ * ver qué se está ejecutando (dueño, 2026-09-11). El que pide lo cuenta en el pedido, pero
220
+ * contar no es probar, así que **si dice estar en esta misma máquina la bóveda lo lee del
221
+ * kernel** (`/proc/<pid>`) y lo compara. Si no cuadra, no es un matiz: es una mentira, y se
222
+ * deniega con un código que se puede buscar.
223
+ *
224
+ * SE MIRA EL PEDIDO, NUNCA EL CANAL. Sería más cómodo comprobar «vino por el socket local»,
225
+ * y está prohibido: el día que un handler decide algo por dónde entró el mensaje, el atajo
226
+ * local deja de ser un atajo y pasa a ser una puerta de atrás (`test/mostrador-local.test.mjs`).
227
+ * El `host` es un dato del pedido como cualquier otro, y mentirlo no consigue nada: lo que
228
+ * se gana es quedar marcado como no comprobado, que es exactamente lo que se quería evitar.
229
+ *
230
+ * Tres resultados, y son tres cosas distintas:
231
+ * · `null` — no dijo nada. Se pide aprobación como siempre y no hay concesión posible.
232
+ * · `verified: 'proc'` — lo leyó el kernel de esta máquina. Es lo que hay que enseñar.
233
+ * · `verified: 'declared'` — lo dice quien pide y no hay forma de comprobarlo (otra
234
+ * máquina, o un sistema sin `/proc`). Vale para decidir, pero la pantalla lo marca.
235
+ *
236
+ * Y EL LÍMITE, QUE NO SE ESCONDE: esto no autoriza nada. Cualquier proceso del mismo
237
+ * usuario puede leer el `service-identity.json` del servicio y pedir con esa llave, y puede
238
+ * llamarse como quiera. La frontera es el usuario, no el proceso: esto existe para que
239
+ * quien aprueba decida mirando lo que pasa, y para que quede rastro.
240
+ */
241
+ function contextoDelPedido (declarado) {
242
+ const dicho = sanitizeContext(declarado)
243
+ if (!dicho) return null
244
+ // Otra máquina: aquí no hay ningún `/proc` que hable de ese proceso.
245
+ if (!dicho.host || dicho.host !== platform.hostname?.()) return { ...dicho, verified: 'declared' }
246
+ const medido = sanitizeContext(platform.readProcContext?.(dicho.pid) ?? null)
247
+ // El proceso ya no está, o no se puede mirar: no se sabe, y no saber se dice como no
248
+ // saber. Inventar un «comprobado» aquí sería el repliegue de siempre.
249
+ if (!medido) return { ...dicho, verified: 'declared' }
250
+ if (commandFingerprint(medido) !== commandFingerprint(dicho)) {
251
+ throw new Error(`ctx — it says it is running "${commandLine(dicho, 60)}" but pid ${dicho.pid} is "${commandLine(medido, 60)}"`)
252
+ }
253
+ return { ...medido, user: dicho.user, host: dicho.host, verified: 'proc' }
254
+ }
255
+ // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
256
+ // reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
257
+ // `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
258
+ // aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
259
+ // además un emparejamiento paralelo al del ecosistema.
260
+ const passwordsFile = 'passwords.json'
261
+ const passwordsAtRest = disk.atRest
262
+ // Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
263
+ const blocked = openBlocked({ disk })
264
+ // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
265
+ const notifiable = openNotifiable({ disk })
266
+ // Dónde corre cada aparato, según lo último que dijo firmado (`src/machines.js`). Se enseña.
267
+ const machines = openMachines({ disk })
268
+ // Lo que hay que contarle a quien aprueba y no es un pedido (`src/notices.js`): hoy, «me actualicé».
269
+ const notices = openNotices({ disk })
270
+ const readPasswordsFile = () => {
271
+ try { return JSON.parse(passwordsAtRest.decrypt(disk.read(passwordsFile))) } catch (_) { return null }
272
+ }
273
+ const writePasswordsFile = (d) => {
274
+ disk.write(passwordsFile, passwordsAtRest.encrypt(JSON.stringify(d)))
275
+ }
276
+
277
+ /** El almacén que espera `@dotrino/passmanager`. Todo en un archivo, cifrado en reposo. */
278
+ const passwordsStore = {
279
+ async get (k) { return readPasswordsFile()?.data?.[k] },
280
+ async set (k, v) {
281
+ const d = readPasswordsFile() || { v: 1, data: {} }
282
+ d.data = { ...(d.data || {}), [k]: v }
283
+ writePasswordsFile(d)
284
+ },
285
+ }
286
+
287
+ // ENTRAR CON USUARIO Y CONTRASEÑA (`passwordLogins.js`). Va cifrado en reposo con la llave
288
+ // de la MÁQUINA y no con la de la contraseña del perfil, por lo mismo que la llave de
289
+ // comunicación: entrar tiene que funcionar con el perfil cerrado. Lo que hay dentro no
290
+ // abre nada por sí solo — el registro de OPAQUE no deja probar contraseñas sin el
291
+ // protocolo, y el paquete de llaves está cerrado con la llave que sale de la contraseña.
292
+ const loginsFile = 'logins.json'
293
+ const loginsAtRest = disk.atRest
294
+ const logins = createLoginDesk({
295
+ load: () => { try { return JSON.parse(loginsAtRest.decrypt(disk.read(loginsFile))) } catch (_) { return null } },
296
+ save: (state) => disk.write(loginsFile, loginsAtRest.encrypt(JSON.stringify(state))),
297
+ // El OPAQUE lo pone la plataforma cuando el suyo no sirve (la extensión lo corre en una
298
+ // página aparte: su CSP no deja instanciar WASM donde vive la bóveda).
299
+ ...(platform.opaque ? { opaque: platform.opaque } : {})
300
+ })
301
+
302
+ /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
303
+ const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
304
+
305
+ /**
306
+ * A QUIÉN SE LE ENVUELVE LA LLAVE DE UNA CONTRASEÑA — Y LA BÓVEDA NUNCA ESTÁ EN LA LISTA.
307
+ *
308
+ * Es la comprobación que sostiene todo el modelo sellado: si la maestra o la llave de
309
+ * comunicación de esta máquina recibieran envoltura, el demonio volvería a poder leer las
310
+ * contraseñas y no habríamos arreglado nada —solo lo habríamos escrito más bonito—. Por
311
+ * eso se filtran explícitamente aquí, que es donde se construye la lista, y no en quien la
312
+ * consume (`sealed-passwords.md` §2.1).
313
+ *
314
+ * `main` son los aparatos con `contraseñas`; `passkeys`, los que además pueden abrir la
315
+ * privada de una passkey (§2.8). Un miembro sin llave de cifrado no puede recibir nada: se
316
+ * queda fuera y se ve en `incompleteMembers`.
317
+ */
318
+ const noEsLaBoveda = (pub) => pub !== master && pub !== commKey.pub()
319
+ const passwordRecipients = (kind = 'main') => (actaCache?.members || [])
320
+ .filter((m) => m.encPub && noEsLaBoveda(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
321
+ .filter((m) => kind !== 'passkeys' || Acta.memberCan(actaCache, m.pub, 'passkeys'))
322
+ .map((m) => ({ pub: m.pub, encPub: m.encPub }))
323
+
324
+ /**
325
+ * El acta vigente, en caché.
326
+ *
327
+ * `isAllowed`/`encPubOf` del responder son SÍNCRONOS (se llaman por cada mensaje que
328
+ * entra, y leer el acta ahí sería un await por mensaje), así que se refresca aparte.
329
+ * Se relee cada pocos segundos: revocar un aparato tarda eso en cortarle el acceso,
330
+ * no un reinicio.
331
+ */
332
+ let actaCache = null
333
+ const refreshActa = async () => {
334
+ try { actaCache = (await identity.profileActa?.().catch(() => null))?.acta || null } catch (_) {}
335
+ return actaCache
336
+ }
337
+
338
+ /**
339
+ * LA LLAVE VIEJA DE LA BÓVEDA DE CONTRASEÑAS, la que había que quitar.
340
+ *
341
+ * Vivía DENTRO del mismo archivo que las entradas, cifrada con la llave de la máquina y
342
+ * no con la contraseña del perfil. O sea: el demonio con el perfil cerrado seguía
343
+ * descifrando, y una copia del disco las abría todas. Esto ya no la crea — solo la LEE,
344
+ * y solo para convertir lo que quedó escrito con ella; en cuanto la conversión termina,
345
+ * `soltarLlaveVieja` la borra del archivo.
346
+ */
347
+ async function llaveViejaDeContrasenas () {
348
+ const d = readPasswordsFile()
349
+ if (!d?.cek) return null
350
+ const raw = b64ToBytes(d.cek)
351
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
352
+ }
353
+ const soltarLlaveVieja = () => {
354
+ const d = readPasswordsFile()
355
+ if (!d?.cek) return
356
+ delete d.cek
357
+ writePasswordsFile(d)
358
+ log('[vault] passwords: the old vault key is gone from the disk')
359
+ audit('passwords.converted', {})
360
+ }
361
+
362
+ const approvalsSweeper = setInterval(() => {
363
+ for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId })
364
+ // Las concesiones vencidas también se anotan: si un servicio vuelve a timbrar el
365
+ // teléfono, esta línea es la que explica por qué (estuvo una hora sin pedir).
366
+ for (const g of grants.sweep()) audit('secrets.grant.expired', { id: g.id, ns: g.ns, device: g.deviceId })
367
+ }, 30 * 1000); approvalsSweeper.unref?.()
368
+ const secrets = openSecretsStore(disk, {
369
+ sealer: makeSealer(),
370
+ // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
371
+ // propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
372
+ // acta, y por eso lo pone el vault: el store no conoce el acta.
373
+ recipients: (owner, opts) => recipientsOf(owner, opts),
374
+ // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
375
+ signer: (body) => signSeal(body),
376
+ // CON QUÉ se cierra la copia de recuperación cuando quien llama no trajo la frase:
377
+ // la llave del perfil si está ABIERTO, y si no la de la máquina. El orden importa —
378
+ // un perfil CON contraseña no se abre con la llave de la máquina, así que sin lo
379
+ // primero un aparato enrolado con la bóveda abierta se quedaba sin su cajón y solo
380
+ // se enteraba al arrancar (dueño, 2026-08-31).
381
+ defaultKey: () => openKey?.() || disk.machineKey()
382
+ })
383
+ // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
384
+ // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
385
+ // con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
386
+ // siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
387
+ // Se dice en voz alta: prometer una protección que no está puesta es peor que no
388
+ // tenerla (docs/secretos-sellados.md §2.3).
389
+ try {
390
+ if (!hasPassword()) {
391
+ log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
392
+ log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
393
+ }
394
+ } catch (_) {}
395
+
396
+ const master = await masterPubkeyOf(identity)
397
+ /**
398
+ * LA HUELLA DE LA CUENTA, no la de esta bóveda. Sale del `profileId` del acta (la pieza
399
+ * compartida la calcula), y con ella se arman la dirección `nombre@AB12-CD34-EF56` y el
400
+ * canal donde se anuncia esta cuenta.
401
+ *
402
+ * Antes salía de la llave de ESTA bóveda. Coinciden mientras la cuenta haya nacido aquí,
403
+ * y dejan de coincidir en cuanto hay una réplica o la bóveda adoptó la cuenta de otro
404
+ * aparato: cada bóveda daba entonces una dirección distinta para la misma cuenta, y el
405
+ * canal las separaba justo cuando lo que hace falta es que se junten.
406
+ */
407
+ const fp = await accountFingerprint(identity)
408
+
409
+ /**
410
+ * El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
411
+ * (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
412
+ * restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
413
+ * máquina y no puede quedar a medias.
414
+ *
415
+ * Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
416
+ * llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
417
+ */
418
+ try {
419
+ const info = await identity.profileActa?.()
420
+ const acta = info?.acta
421
+ // CON EL PERFIL ABIERTO. Estrenar la llave de sellado SELLA EL ACTA, y una bóveda
422
+ // cerrada no sella nada — la misma regla que el bloque de abajo. Cerrada se queda sin
423
+ // llave y los sobres salen sin firma (lo que ya pasaba antes de §8.8), y se arregla sola
424
+ // la primera vez que alguien abra el perfil.
425
+ if (info?.isMaster && acta && !isLocked() && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
426
+ const r = await identity.rotateSealKey()
427
+ // Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
428
+ // primero y `console.log` no lo sustituye (salía «record #%s 2»).
429
+ log(`[vault] new sealing key in record #${r.seq}`)
430
+ }
431
+ } catch (e) { log('[vault] could not set up the sealing key:', e.message) }
432
+
433
+ /**
434
+ * LA LLAVE DE COMUNICACIÓN, dentro del acta.
435
+ *
436
+ * La maestra sella el acta y reenvuelve sobres; hablar por la red no es suyo. Esta es la
437
+ * que se identifica ante el proxio, y su autoridad la dice el ACTA: entra como un miembro
438
+ * más, con `cn: 'vault'`, así que el acta la trata como un servicio del perfil — puede
439
+ * hablar por la bóveda, no firmar por la persona.
440
+ *
441
+ * Solo se puede meter con el perfil ABIERTO (admitir un miembro es sellar el acta, y eso
442
+ * es de la maestra). Con el perfil cerrado no se toca nada: si ya está, se usa; y si no,
443
+ * `identify` se repliega a la maestra y lo dice. Por eso una bóveda que se actualiza
444
+ * tiene que abrirse UNA vez, y a partir de ahí ya puede vivir cerrada.
445
+ */
446
+ /**
447
+ * METER LA LLAVE DE COMUNICACIÓN EN EL ACTA, si el perfil está abierto.
448
+ *
449
+ * `isLocked()` es la pregunta correcta — no `masterLocked`. Se coló así: en la PRIMERA
450
+ * arrancada tras actualizar, la maestra todavía está guardada en claro (se sella al abrir
451
+ * el perfil), o sea que `masterLocked` es `false` aunque el perfil esté cerrado. Con ese
452
+ * guardián, la bóveda de producción admitió su llave de comunicación y SELLÓ UN ACTA
453
+ * NUEVA (#76 → #77) estando cerrada — justo lo que la regla prohíbe, y encima rotando de
454
+ * paso la llave de sellado sin que nadie lo pidiera.
455
+ *
456
+ * SE LLAMA TAMBIÉN AL ABRIR, y esa es la mitad que faltaba. Antes solo corría al arrancar
457
+ * el servicio, y **un perfil con contraseña arranca cerrado siempre**: nunca pasaba por
458
+ * aquí, así que jamás llegaba a tener su llave. O sea que los perfiles que MÁS la
459
+ * necesitan —los protegidos, los que no pueden firmar con la maestra estando cerrados—
460
+ * eran precisamente los únicos que se quedaban sin ella, y seguían identificándose con la
461
+ * maestra o quedándose mudos. Se vio el 2026-09-02: de dos perfiles, el que tenía
462
+ * contraseña era el que no tenía `commkey.json`.
463
+ */
464
+ async function ensureCommKeyInActa () {
465
+ try {
466
+ const info = await identity.profileActa?.()
467
+ const acta = info?.acta
468
+ if (!acta || isLocked() || identity.masterLocked) return false
469
+ const pub = await commKey.ensure()
470
+ if ((acta.members || []).some((m) => m?.pub === pub)) return false
471
+ if (!info?.isMaster) return false
472
+ await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
473
+ log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
474
+ return true
475
+ } catch (e) {
476
+ log('[vault] could not put the communication key in the record:', e.message)
477
+ return false
478
+ }
479
+ }
480
+ await ensureCommKeyInActa()
481
+
482
+ // `announce`: el canal de ESTA cuenta, para que una máquina prestada —que no tiene
483
+ // ninguna llave y solo sabe la dirección `nombre@AB12-CD34-EF56`— pueda encontrar a quien
484
+ // la atiende. Lo construye la pieza compartida, porque quien lo busca es la extensión.
485
+ const { client, identify: reidentificar, isIdentified } = await createTransport({
486
+ identity, url: proxyUrl, commKey, log, announce: vaultChannel(fp), makeClient: platform.makeClient || null
487
+ })
488
+
489
+ // El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
490
+ // que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
491
+ // `sealers.js` — es una comodidad para quien lee, nunca una dependencia de la bóveda.
492
+ startSealersPublisher({ identity, client, log })
493
+
494
+ async function revocationSet () {
495
+ const { revoked } = await identity.listDelegations()
496
+ return new Set(revoked.map((r) => r.nonce))
497
+ }
498
+
499
+ /**
500
+ * CONTESTAR SIN MATAR LA CONEXIÓN.
501
+ *
502
+ * El proxio corta los frames a 1 MB (`maxPayload`), y `ws` no «descarta» el que se pasa:
503
+ * CIERRA EL SOCKET con un 1009. La bóveda se queda muda, sin un solo error en su log, y
504
+ * desde fuera se ve igual que si estuviera apagada. Eso ya pasó y duró tres días.
505
+ *
506
+ * Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
507
+ * Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
508
+ */
509
+ /**
510
+ * QUIÉN CONTESTA A QUIÉN. Por defecto el proxio; un remitente LOCAL (el socket de esta
511
+ * máquina) se apunta aquí con su propia forma de contestar.
512
+ *
513
+ * Que haya dos transportes no cambia NADA de quién puede pedir qué: la autorización sale
514
+ * del certificado y del acta, igual por los dos caminos. Alcanzar el socket no autoriza
515
+ * nada — es la misma puerta, con el camino más corto.
516
+ */
517
+ const canales = new Map()
518
+
519
+ /**
520
+ * QUÉ SOMOS, Y QUÉ CORRE CADA APARATO (CONVENCIONES §14, `@dotrino/compat`).
521
+ *
522
+ * El agujero que esto tapa: una incompatibilidad de versiones se manifestaba como
523
+ * SILENCIO. El que llamaba no sabía si estábamos apagados, ocupados o hablando otro
524
+ * idioma, así que reintentaba para siempre — y aquí no se sabía que nos hablaban.
525
+ *
526
+ * **Informa, no bloquea** (dueño, 2026-09-04): el dictamen se enseña y se le pega a los
527
+ * errores, y no hay ni un `if` que deje de atender por él. Si algún día se decide
528
+ * bloquear, se decide arriba y se escribe aquí a propósito.
529
+ *
530
+ * La lista de rotas sale del registro común (`@dotrino/roadmap`), que viaja DENTRO de
531
+ * esta versión: la compatibilidad es política del código, no de la cuenta.
532
+ */
533
+ const MI_VERSION = compatDeclare({
534
+ product: platform.product || 'vaultd', version: VERSION, protocol: VAULT_PROTOCOL, speaks: VAULT_SPEAKS
535
+ })
536
+ let ROTAS = []
537
+ try { ROTAS = brokenOf(loadManifest()) } catch (e) { log('[vault] no compatibility registry: ' + e.message) }
538
+
539
+ /**
540
+ * Lo que dice correr cada aparato, por llave. Se apunta de lo que viene FIRMADO
541
+ * (`p.data.v`, dentro del sobre) y no del envoltorio: una versión que cualquiera puede
542
+ * escribir no vale para enseñarla como si fuera un dato.
543
+ */
544
+ const versiones = new Map()
545
+ const apuntarVersion = (pub, v) => {
546
+ if (!pub || !v) return
547
+ const dictamen = compatCheck({ mine: MI_VERSION, theirs: v, broken: ROTAS })
548
+ versiones.set(pub, { ...v, at: Date.now(), compatible: dictamen.compatible, reason: dictamen.reason })
549
+ if (!dictamen.compatible) log(`[vault] ${v.product} ${v.version}: ${dictamen.reason}`)
550
+ return dictamen
551
+ }
552
+ /** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
553
+ const versionDe = (pub) => versiones.get(pub) || null
554
+ /** Dónde corre un aparato: lo último que dijo firmado (`machines`) o, si no, lo del acta. */
555
+ const machineOf = (pub, record) => machines.of(pub) || (record?.members || []).find((m) => m.pub === pub)?.machine || null
556
+
557
+ // ----- LA SUBACTA: lo decidido que todavía no selló el acta (`src/subacta.js`) -----
558
+ //
559
+ // Sellar es de la maestra, así que con el perfil CERRADO una renuncia no surtía ningún
560
+ // efecto: el aparato conservaba todos sus permisos hasta que alguien fuera a la máquina
561
+ // a teclear la contraseña. Justo el caso que la renuncia existe para cubrir.
562
+ //
563
+ // Se honra ya, sin sellar, y es seguro porque una entrada de la subacta SOLO PUEDE
564
+ // QUITAR: no concede nada a nadie, y va firmada por el miembro al que afecta.
565
+ const subacta = openSubacta(disk)
566
+
567
+ /**
568
+ * EL ACTA, LEÍDA CON LA SUBACTA EN LA MANO.
569
+ *
570
+ * Envuelve al pilar en UN sitio en vez de pasar la lista en los diecisiete mostradores:
571
+ * una comprobación repetida diecisiete veces son dieciséis sitios donde olvidarla, y
572
+ * olvidarla aquí significa atender a un aparato que ya renunció.
573
+ *
574
+ * Solo se tocan las cinco que DECIDEN. El resto del pilar pasa tal cual.
575
+ */
576
+ const Acta = {
577
+ ...ActaPilar,
578
+ memberCan: (acta, pub, cap) => ActaPilar.memberCan(acta, pub, cap, subacta.forMember(pub)),
579
+ memberCanScope: (acta, pub, scope) => ActaPilar.memberCanScope(acta, pub, scope, subacta.forMember(pub)),
580
+ memberCanReadSecrets: (acta, pub, ns) => ActaPilar.memberCanReadSecrets(acta, pub, ns, subacta.forMember(pub)),
581
+ memberCanSign: (acta, pub, ns = null) => ActaPilar.memberCanSign(acta, pub, ns, subacta.forMember(pub)),
582
+ memberScopes: (acta, pub) => ActaPilar.memberScopes(acta, pub, subacta.forMember(pub)),
583
+ effectiveCaps: (acta, pub) => ActaPilar.effectiveCaps(acta, pub, subacta.forMember(pub))
584
+ }
585
+
586
+ /**
587
+ * ABSORBER lo pendiente en el acta. Solo con la bóveda abierta, que es la única que
588
+ * sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
589
+ * y tirarla sería perder una renuncia sin aplicar.
590
+ */
591
+ async function absorberSubacta (motivo = 'unlock') {
592
+ const pendientes = subacta.drain()
593
+ if (!pendientes.length) return 0
594
+ const hechas = []
595
+ for (const e of pendientes) {
596
+ try { await identity.absorbRenounce(e); hechas.push(e) }
597
+ catch (err) { log(`[vault] ${motivo}: could not absorb a pending renounce: ${err.message}`) }
598
+ }
599
+ if (hechas.length) {
600
+ subacta.clear(hechas)
601
+ log(`[vault] ${motivo}: ${hechas.length} pending renounce(s) absorbed into the record`)
602
+ }
603
+ return hechas.length
604
+ }
605
+
606
+ /**
607
+ * LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
608
+ * `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
609
+ * lo que se apunta es lo que ese aparato dijo **con su llave**, no lo que cualquiera
610
+ * pueda escribir por fuera. Un inventario de versiones que se puede falsificar no es un
611
+ * inventario, es una pantalla bonita.
612
+ *
613
+ * Va envuelto y no repetido en los ocho mostradores porque una comprobación copiada
614
+ * ocho veces son siete sitios donde olvidarla.
615
+ */
616
+ const verifyChain = async (args) => {
617
+ const chk = await verifyChainRaw(args)
618
+ if (chk.ok && args?.data?.v) apuntarVersion(chk.device, args.data.v)
619
+ // DÓNDE CORRE, por el mismo motivo: se apunta de lo firmado. Un fallo al guardarlo no
620
+ // puede tumbar el pedido —es un dato para la lista—, pero se dice.
621
+ if (chk.ok && args?.data?.machine) {
622
+ try { machines.note(chk.device, args.data.machine) } catch (e) { log(`[vault] could not record where a device runs: ${e.message}`) }
623
+ }
624
+ // UN BLOQUEADO NO PASA POR NINGÚN MOSTRADOR. Sigue en el acta —bloquear no la toca,
625
+ // porque la bóveda vive cerrada y eso lo decide un aprobador desde el teléfono—, así que
626
+ // el papel verifica; la puerta se cierra aquí, que es por donde entra todo, y con su
627
+ // propio `reason` para que el otro lado sepa qué le pasa y no lo confunda con un papel
628
+ // viejo (que se arregla renovando) ni con una revocación (que se arregla emparejando).
629
+ if (chk.ok && blocked.has(chk.device)) return { ok: false, reason: 'blocked', device: chk.device }
630
+ return chk
631
+ }
632
+
633
+
634
+ const reply = (to, obj) => {
635
+ try {
636
+ const enviar = canales.get(to) || ((o) => client.send(to, o))
637
+ // ANUNCIAMOS EN CADA RESPUESTA: el otro lado decide con esto, y decide él porque es
638
+ // el que puede tener la lista al día (§14). Va en el envoltorio y no dentro de un
639
+ // cuerpo firmado a propósito — es un dato para enseñar y para explicar un fallo, no
640
+ // una credencial: nada se autoriza con él.
641
+ obj = { ...obj, v: MI_VERSION }
642
+ const bytes = byteLength(JSON.stringify(obj))
643
+ if (bytes > MAX_REPLY_BYTES) {
644
+ log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
645
+ audit('reply-too-big', { type: obj?.type || null, bytes })
646
+ return enviar({ type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
647
+ }
648
+ enviar(obj)
649
+ } catch (e) { log('[vault] could not reply:', e.message) }
650
+ }
651
+
652
+ // FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
653
+ // ventana de ±5 min (mismo criterio que el identify del proxy). Sin esto, un
654
+ // relay malicioso podía REPRODUCIR mensajes firmados viejos (re-pedir firmas,
655
+ // abrir renovaciones…) durante toda la vida del cert.
656
+ // AUDITORÍA: bitácora de actividad de seguridad (activity.log, JSONL) — qué
657
+ // dispositivo firmó/renovó/enroló y qué se rechazó. `dotrino-vault activity`
658
+ // la muestra. Sin contenido de payloads (privacidad): solo op, dispositivo, hora.
659
+ //
660
+ // VA CIFRADA, y una línea a la vez. Antes era el único archivo excluido del reposo, con
661
+ // el argumento de que «no guarda payloads»: no guarda valores, pero sí el mapa entero de
662
+ // la cuenta —qué aparatos hay, con qué permisos, contra qué cajones y a qué hora—, que
663
+ // es justo lo que quiere quien está eligiendo por dónde entrar. Y encima se creaba 0664,
664
+ // legible por cualquier usuario de la máquina.
665
+ //
666
+ // Cifrar por línea (y no el archivo entero) es lo que deja seguir AÑADIENDO: cada
667
+ // entrada se cierra sola, así que un `appendFileSync` sigue costando lo mismo y la
668
+ // rotación no necesita abrir nada.
669
+ const activityFile = 'activity.log'
670
+ const activityAtRest = disk.atRest
671
+ // MIGRACIÓN de las bitácoras escritas antes de 0.89, que están en claro. Se convierten
672
+ // de una vez al arrancar: es un archivo acotado (rota al MB) y así «nada en claro» es
673
+ // cierto también para lo que ya estaba escrito, no solo para lo que se escriba desde
674
+ // ahora. Se agota sola — cuando no queda ninguna línea en claro, no vuelve a tocar nada.
675
+ try {
676
+ const raw = disk.read(activityFile)
677
+ if (raw == null) throw new Error('no log yet')
678
+ const lines = raw.split('\n')
679
+ if (lines.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
680
+ const sealedLines = lines.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
681
+ // Escribir aparte y renombrar: a medio convertir, la bitácora sería ilegible entera.
682
+ disk.write(activityFile, sealedLines.join('\n'))
683
+ // Sin `%d`: el `log` de la bóveda no formatea, así que el placeholder salía literal.
684
+ log(`[vault] activity log: ${sealedLines.filter(Boolean).length} line(s) sealed at rest`)
685
+ }
686
+ } catch (_) { /* no hay bitácora todavía */ }
687
+ /**
688
+ * LA BITÁCORA ES UNA CADENA, no una lista de líneas sueltas.
689
+ *
690
+ * Cifrarla (0.89) le dio confidencialidad y ninguna integridad: quien tenga la llave de
691
+ * la máquina puede reescribir una línea y nadie lo nota. Como evidencia no valía nada, y
692
+ * eso es justo lo que un auditor mira (`CUMPLIMIENTO.md` §2).
693
+ *
694
+ * Cada entrada lleva su `seq` y el **hash de la anterior**, así que tocar o quitar una
695
+ * rompe la cadena de ahí en adelante y se ve. Se encadena el TEXTO exacto de la línea,
696
+ * no un objeto re-serializado: así verificar no depende de que las claves salgan en el
697
+ * mismo orden.
698
+ *
699
+ * **Lo que esto NO resuelve, dicho para no venderlo de más:** quien tenga la máquina
700
+ * puede cortar el final de la cadena y quedarse con un prefijo válido. Eso no se cierra
701
+ * en local — pide anclar el último hash fuera (un TSA, otro aparato). Lo que sí cierra
702
+ * es reescribir el pasado sin dejar rastro, que es el caso que importaba.
703
+ */
704
+ const AUDIT_MAX_BYTES = 1024 * 1024
705
+ /** Cuántos archivos viejos se guardan. Es la RETENCIÓN, y va declarada. */
706
+ const AUDIT_KEEP = 5
707
+ const sha256 = sha256Hex
708
+
709
+ let auditSeq = 0
710
+ let auditPrev = null
711
+
712
+ /** Reconstruye el estado de la cadena leyendo la última entrada que haya. */
713
+ try {
714
+ const lines = (disk.read(activityFile) || '').split('\n').filter(Boolean)
715
+ for (let i = lines.length - 1; i >= 0; i--) {
716
+ try {
717
+ const text = activityAtRest.decrypt(lines[i])
718
+ const e = JSON.parse(text)
719
+ if (typeof e.logSeq === 'number') { auditSeq = e.logSeq; auditPrev = sha256(text); break }
720
+ } catch (_) { /* una línea vieja o ilegible: se sigue hacia atrás */ }
721
+ }
722
+ } catch (_) { /* no hay bitácora todavía */ }
723
+
724
+ /**
725
+ * ROTA ARCHIVANDO, NO TIRANDO. Antes se quedaba con la mitad del archivo y la otra mitad
726
+ * desaparecía sin que nada lo dijera: una bitácora que pierde historia en silencio es
727
+ * peor que no tenerla, porque parece completa. Ahora los viejos se corren a `.1`…`.N` y,
728
+ * cuando uno se cae del borde, **queda anotado que se cayó**.
729
+ */
730
+ const rotateAudit = () => {
731
+ let dropped = null
732
+ const older = `${activityFile}.${AUDIT_KEEP}`
733
+ try { if (disk.exists(older)) { disk.remove(older); dropped = `activity.log.${AUDIT_KEEP}` } } catch (_) {}
734
+ for (let i = AUDIT_KEEP - 1; i >= 1; i--) {
735
+ try { if (disk.exists(`${activityFile}.${i}`)) disk.rename(`${activityFile}.${i}`, `${activityFile}.${i + 1}`) } catch (_) {}
736
+ }
737
+ try { disk.rename(activityFile, `${activityFile}.1`) } catch (_) {}
738
+ return dropped
739
+ }
740
+
741
+ const audit = (op, info = {}) => {
742
+ try {
743
+ // LOS CAMPOS DE LA CADENA VAN AL FINAL Y CON NOMBRE PROPIO, y las dos cosas por el
744
+ // mismo motivo: que quien llama no pueda pisarlos.
745
+ //
746
+ // Estaban delante y se llamaban `seq`/`prev`, así que `...info` los sobrescribía. Y
747
+ // no era hipotético: `renew` ya mandaba un `seq` —el del ACTA, que no tiene nada que
748
+ // ver— y dejaba dos entradas seguidas numeradas igual. Lo cazó el propio verificador
749
+ // contra la bitácora de producción a los diez minutos de desplegarlo, que es
750
+ // exactamente para lo que sirve.
751
+ const entry = { ...info, ts: Date.now(), op, logSeq: ++auditSeq, logPrev: auditPrev }
752
+ const text = JSON.stringify(entry)
753
+ disk.append(activityFile, activityAtRest.encrypt(text) + '\n')
754
+ auditPrev = sha256(text)
755
+ if (disk.size(activityFile) > AUDIT_MAX_BYTES) {
756
+ const dropped = rotateAudit()
757
+ // La primera entrada del archivo nuevo dice de dónde viene, y si se perdió algo.
758
+ audit('log.rotated', { keep: AUDIT_KEEP, ...(dropped ? { dropped } : {}) })
759
+ }
760
+ } catch (_) {}
761
+ }
762
+
763
+ const FRESH_WINDOW_MS = 5 * 60 * 1000
764
+ const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
765
+ const staleReply = (from) => reply(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
766
+
767
+ // --- ENROLL / aprobación / revocación: núcleo COMPARTIDO (lib/src/enroll.js) ---
768
+ // El mismo módulo lo usan «este dispositivo es bóveda» (@dotrino/vault) y la copia
769
+ // vendorizada del iframe de identidad: un solo sitio donde vive el flujo, y por lo
770
+ // tanto un solo sitio donde se comprueba el código antes de firmar el cert.
771
+ const desk = createEnrollDesk({
772
+ identity,
773
+ iss: master,
774
+ proxy: client.url,
775
+ send: (to, obj) => reply(to, obj),
776
+ sendByPubkey: (pub, obj) => client.sendByPubkey(pub, obj),
777
+ audit,
778
+ log,
779
+ // Camino A: lo que esta bóveda le manda al aparato para entrar en SU acta. La llave de
780
+ // cifrado es lo que le permite leer el contenido de la cuenta que va a custodiar; sin
781
+ // ella entraría mandando una cuenta que no puede abrir.
782
+ encPub: identity.me?.encryptionPubkey || null,
783
+ vaultLabel: 'bóveda',
784
+ // Lo único que lleva el QR corto: una CITA del proxio, que es un código de 6
785
+ // caracteres de un solo uso y con minutos de vida. Antes iba la dirección de
786
+ // la conexión, que eran 4 caracteres; hoy esa dirección es una instancia de
787
+ // 24 (para poder rutearla entre proxios) y no cabe cómoda en un QR ni
788
+ // conviene dejarla impresa en algo que circula. Se pide una por
789
+ // emparejamiento: si el proxio es viejo y no las conoce, se cae solo a la
790
+ // invitación larga, que sigue funcionando.
791
+ connToken: async () => {
792
+ try { return (await client.requestPairingCode())?.code || null }
793
+ catch (_) { return null }
794
+ },
795
+ onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
796
+ // Se va el aparato, se van SUS variables. Guardarlas sería configuración de una llave
797
+ // que ya no entra, y volvería a la vida sola el día que se enrole otro aparato con esa
798
+ // misma llave. Va aquí porque a quitar se entra por dos puertas (el PC y la consola
799
+ // remota) y las dos pasan por `desk.revokeDevice`.
800
+ onDeviceRemoved: (sub) => {
801
+ try {
802
+ const n = secrets.forgetDevice(sub)
803
+ if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
804
+ } catch (e) { log('[vault] could not drop the device variables:', e.message) }
805
+ // Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
806
+ // a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
807
+ // la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
808
+ //
809
+ // Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
810
+ // emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
811
+ // Un interruptor que pide una frase que quizá no tienes a mano no es un
812
+ // interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
813
+ markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
814
+ },
815
+ defaultScope: [SCOPE.READ],
816
+ onChallenge ({ deviceId, scope, machine }) {
817
+ log(`\n[vault] Un dispositivo quiere conectarse:`)
818
+ log(` deviceId: ${deviceId}`)
819
+ if (machine) log(` machine: ${machineLine(machine)}`)
820
+ log(` Ingresa el código que MUESTRA el dispositivo:`)
821
+ log(` dotrino-vault approve <código> (o rechaza: dotrino-vault reject ${deviceId})\n`)
822
+ try { onEnrollChallenge?.({ deviceId, scope, machine: machine || null }) } catch (_) {}
823
+ }
824
+ })
825
+
826
+ /**
827
+ * EL ACTA MANDA, EL PAPEL SOLO ACOMPAÑA. Se pregunta DESPUÉS de verificar la cadena y
828
+ * ANTES de hacer nada, en todos los mostradores.
829
+ *
830
+ * El certificado dice a qué se comprometió esta bóveda cuando conectó el aparato; el
831
+ * acta dice lo que puede HOY. Y no coinciden: `caps <ID> -lee` sella el acta pero no
832
+ * reemite ni revoca el papel, que vive hasta 30 días. Sin esto, quitarle un permiso a un
833
+ * aparato no se lo quitaba — seguía leyendo, guardando o firmando un mes más.
834
+ *
835
+ * Va por `memberCanScope` (el pilar) y no a mano en cada sitio: la regla es la misma en
836
+ * las dos bóvedas y se comprobaba mal en las dos.
837
+ *
838
+ * @returns {boolean} `true` si puede seguir; si no, ya contestó el «no».
839
+ */
840
+ /**
841
+ * CON QUÉ SE JUZGA UN PAPEL: el acta que tiene esta bóveda.
842
+ *
843
+ * Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
844
+ * multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
845
+ * los rechazaban los diez mostradores. Ahora se compara contra la lista que dice el acta.
846
+ *
847
+ * Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
848
+ * mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
849
+ * se decide que sí.
850
+ */
851
+ async function contextoActa () {
852
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
853
+ if (!acta) return { actaSeq: null, sealers: null }
854
+ return { actaSeq: acta.seq, sealers: Acta.sealersOf(acta) }
855
+ }
856
+
857
+ async function actaAllows (from, chk, scope, what) {
858
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
859
+ // SIN ACTA NO SE ATIENDE. Esto era `if (!record || puede())`, o sea: sin acta, pasa. Un
860
+ // repliegue no dice «por si falta el dato», dice «si falta el dato, di que sí» — y el
861
+ // acta falta justo cuando algo se rompió (un archivo a medias, un `catch` que devolvió
862
+ // null), que es cuando menos hay que fiarse. Toda cuenta tiene acta desde hace versiones.
863
+ if (!record) {
864
+ audit('rejected', { what, reason: 'sin-acta' })
865
+ reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
866
+ return false
867
+ }
868
+ if (Acta.memberCanScope(record, chk.device, scope)) return true
869
+ audit('rejected', { what, reason: 'acta' })
870
+ reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
871
+ return false
872
+ }
873
+
874
+ // --- handleSign / handleGet: idénticos (verifyChain de la cadena D←maestra) ---
875
+ async function handleSign (from, p) {
876
+ if (!isFresh(p.data)) { audit('rejected', { what: 'sign', reason: 'stale' }); return staleReply(from) }
877
+ const chk = await verifyChain({
878
+ data: p.data, signature: p.signature, cert: p.cert,
879
+ expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked: await revocationSet()
880
+ })
881
+ if (!chk.ok) return denyChain(from, chk, p, 'sign')
882
+
883
+ // MANDA EL ACTA, NO EL PAPEL. Esto miraba solo el certificado, y era el único
884
+ // mostrador que lo hacía —`approve` y los secretos ya preguntaban al acta—. El agujero
885
+ // que dejaba: `caps <ID> -firma` quita `sign` del acta pero NO reemite ni revoca el
886
+ // cert del aparato, que sigue diciendo `vault:sign` hasta 30 días. Entonces su
887
+ // `signData` local veía que ya no puede y se lo PEDÍA a la bóveda… que decía que sí.
888
+ // O sea que quitarle el permiso de firmar no se lo quitaba: lo cambiaba de firmar él
889
+ // a que firmaras tú por él.
890
+ if (!await actaAllows(from, chk, SCOPE.SIGN, 'sign')) return
891
+
892
+ // Y CERRADA NO FIRMA NADA (dueño, 2026-08-31). La regla de antes —«el daemon sigue
893
+ // firmando con el perfil bloqueado, para que un reinicio no deje las apps muertas»— es
894
+ // ANTERIOR AL MODELO DE SOBRES y queda derogada: con los sobres la bóveda no necesita
895
+ // firmar nada para servir. Lo que hace abierta es rehacer los sobres.
896
+ //
897
+ // Y las apps no se quedan muertas por esto: un aparato al que el acta le da `sign`
898
+ // firma con SU llave y no pasa por aquí (ver `signData` en el pilar). Leer y guardar
899
+ // tampoco pasan por aquí, y siguen funcionando con el candado echado.
900
+ if (isLocked()) {
901
+ audit('rejected', { what: 'sign', reason: 'locked' })
902
+ return reply(from, { type: MSG.ERROR, error: 'vault locked: the master key does not sign while the vault is closed' })
903
+ }
904
+ const toSign = p.data?.payload
905
+ if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload required' })
906
+ const { signature, publickey } = await identity.signData(toSign)
907
+ audit('sign', { device: await deviceIdOf(chk.device) })
908
+ reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
909
+ }
910
+
911
+ async function handleGet (from, p) {
912
+ if (!isFresh(p.data)) return staleReply(from)
913
+ const chk = await verifyChain({
914
+ data: p.data, signature: p.signature, cert: p.cert,
915
+ expectedScope: SCOPE.READ, ...(await contextoActa()), revoked: await revocationSet()
916
+ })
917
+ if (!chk.ok) return denyChain(from, chk, p, 'get')
918
+ if (!await actaAllows(from, chk, SCOPE.READ, 'get')) return
919
+ const id = p.data?.id || 'root'
920
+ reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
921
+ }
922
+
923
+ // Store de hilos+aperturas (Fase 3): escrituras requieren vault:store; lecturas
924
+ // aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
925
+ /**
926
+ * LOS DATOS DEL PERFIL (`docs/datos-del-perfil.md`). Tres métodos y ninguno más:
927
+ *
928
+ * · `profilePut` — guarda un dato. Privado = sobre ya sellado; público = en claro.
929
+ * · `profileBundle` — los sobres de ESTE aparato, para que arme su perfil.
930
+ * · `profilePublic` — lo público en claro, que puede pedir cualquiera.
931
+ *
932
+ * Lo que NO se comprueba aquí: el candado. Un sobre sellado no lo puede leer ni fabricar
933
+ * la bóveda, así que guardarlo no es una decisión suya y no necesita su maestra. Ese era
934
+ * el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
935
+ * todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
936
+ */
937
+ /**
938
+ * ABRIR LA BÓVEDA DESDE EL ADMIN (`docs/abrir-a-distancia.md`).
939
+ *
940
+ * Dos pasos, y el segundo no se puede reproducir por DOS candados distintos:
941
+ *
942
+ * · el **nonce** hace que se NIEGUE a atenderlo dos veces (memoria, 10 min);
943
+ * · la **efímera** hace que no se PUEDA abrir dos veces — se tira al usarla, así que
944
+ * ese sobre queda inabrible hasta para nosotros, incluso con el disco en la mano.
945
+ *
946
+ * Por qué una efímera y no la llave de cifrado fija de la bóveda: esa cede a la llave de
947
+ * la máquina (modelo de amenazas §4.2), o sea que cae con el disco. La contraseña es
948
+ * justo lo único que un ladrón de disco NO tiene — cifrarla con algo que el disco
949
+ * entrega sería regalársela a quien grabe el tráfico hoy y robe el disco mañana.
950
+ *
951
+ * Y el molino lo hace el ADMIN: aquí llega la derivada, nunca la contraseña.
952
+ */
953
+ const EPHEMERAL_TTL_MS = 60 * 1000
954
+ const aperturas = new Map() // nonce -> { privateKey, exp }
955
+
956
+ const unlockDesk = (typeof openWithSecondary === 'function' && typeof secondaryParams === 'function')
957
+ ? {
958
+ async begin ({ by }) {
959
+ const params = secondaryParams()
960
+ if (!params) {
961
+ throw new Error('this profile has no admin password: set one with `dotrino-vault profile admin-password`')
962
+ }
963
+ // Se barre lo vencido aquí y no con un temporizador: es barato, y un temporizador
964
+ // más es una cosa más que puede quedarse corriendo cuando el perfil ya no existe.
965
+ const t = Date.now()
966
+ for (const [n, v] of aperturas) if (v.exp <= t) aperturas.delete(n)
967
+ const eph = await makeEphemeralKey()
968
+ aperturas.set('pendiente', { privateKey: eph.privateKey, exp: t + EPHEMERAL_TTL_MS })
969
+ audit('unlock.begin', { by })
970
+ return { ...params, ek: eph.ek }
971
+ },
972
+
973
+ async open ({ nonce, enc, by }) {
974
+ const guardada = aperturas.get('pendiente')
975
+ aperturas.delete('pendiente')
976
+ if (!guardada || guardada.exp <= Date.now()) {
977
+ return { ok: false, error: 'unlock: ask for a key first, and use it within a minute' }
978
+ }
979
+ let payload = null
980
+ try { payload = await openSealed({ privateKey: guardada.privateKey, enc }) } catch (_) { payload = null }
981
+ // EL NONCE VA DENTRO DEL SOBRE. Si fuera solo por fuera, un sobre capturado se
982
+ // reenvía con un nonce nuevo y el candado del nonce no sirve de nada.
983
+ if (!payload || payload.nonce !== nonce || typeof payload.key !== 'string') {
984
+ audit('rejected', { what: 'unlock', by, reason: 'sobre' })
985
+ return { ok: false, error: 'unlock: the envelope does not check out' }
986
+ }
987
+ const derivada = b64ToBytes(payload.key)
988
+ try {
989
+ const r = await openWithSecondary(derivada)
990
+ audit('unlock', { by })
991
+ log(`[vault] opened from the admin by ${by || '????-????'}`)
992
+ return { ok: true, result: { ok: true, locked: false } }
993
+ } catch (e) {
994
+ audit('rejected', { what: 'unlock', by, reason: e.code || 'error' })
995
+ // El freno viaja con el rechazo: sin él, el admin reintenta contra una puerta
996
+ // que ya no responde y no sabe por qué.
997
+ return { ok: false, code: e.code || null, error: e.message, tries: e.tries ?? null, waitSec: e.waitSec ?? null }
998
+ } finally {
999
+ derivada.fill(0)
1000
+ }
1001
+ }
1002
+ }
1003
+ : null
1004
+
1005
+ const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
1006
+
1007
+ async function handleProfile (from, p, d) {
1008
+ // LO PÚBLICO NO PIDE NADA. Es lo que ve quien pregunta desde fuera y no tiene ninguna
1009
+ // llave tuya: pedir un papel para leerlo sería contradecir lo que significa «público».
1010
+ // Va firmado, así que quien lo recibe comprueba que es tuyo sin fiarse de nadie.
1011
+ if (d.method === 'profilePublic') {
1012
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: secrets.profilePublic() })
1013
+ }
1014
+ const revoked = await revocationSet()
1015
+ // `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
1016
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
1017
+ if (!chk.ok) return denyChain(from, chk, p, 'profile')
1018
+ if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
1019
+
1020
+ // A QUIÉN HAY QUE ENVOLVERLE. Lo pide el aparato ANTES de sellar, porque sellar lo hace
1021
+ // él: envolver solo necesita públicas, así que puede hacerlo un navegador sin que
1022
+ // ninguna privada ande suelta. Sale de aquí y no de una lista suya para que conceder
1023
+ // `firma` a un aparato nuevo baste — sin que nadie tenga que acordarse de nada.
1024
+ //
1025
+ // VA DETRÁS DEL CONTROL, y estuvo delante. Que solo haga falta material PÚBLICO para
1026
+ // envolver no significa que la lista sea pública: lo que devuelve es el INVENTARIO
1027
+ // —cuántas máquinas tienes, sus llaves, y la de recuperación— y eso la bóveda no se lo
1028
+ // enseña ni a un servicio de la casa (`modelo-de-amenazas.md` §2.5). Cualquiera que
1029
+ // supiera la llave de la cuenta, que es pública, lo sacaba entero preguntando. Y no
1030
+ // costaba nada evitarlo: la consola YA manda su firma y su papel en esta misma llamada
1031
+ // (`requestStore` de `@dotrino/identity`), solo que aquí no se miraban.
1032
+ if (d.method === 'profileRecipients') {
1033
+ const members = await recipientsOf(PROFILE_OWNER)
1034
+ return reply(from, {
1035
+ type: MSG.STORE_RESULT,
1036
+ method: d.method,
1037
+ result: { recoveryPub: secrets.recoveryPub(), members: members.map((m) => ({ pub: m.pub, encPub: m.encPub })) }
1038
+ })
1039
+ }
1040
+
1041
+ try {
1042
+ if (d.method === 'profileBundle') {
1043
+ const b = secrets.profileBundleFor(chk.device)
1044
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: b })
1045
+ }
1046
+ // profilePut
1047
+ const { key, cls, sobre, value } = d.args || {}
1048
+ const quien = await deviceIdOf(chk.device).catch(() => null)
1049
+ let r
1050
+ if (cls === 'public') {
1051
+ r = await secrets.profilePutPublic(key, value, { by: quien })
1052
+ } else {
1053
+ r = await secrets.putSealed(PROFILE_OWNER, key, sobre, { by: quien })
1054
+ }
1055
+ audit('profile.put', { key, cls: cls || 'private', by: quien, gen: r?.gen ?? null })
1056
+ // Y a los demás aparatos, que si no se enteran al arrancar y vuelve la divergencia.
1057
+ await notifyMembers('profile', { key, by: quien })
1058
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: r })
1059
+ } catch (e) {
1060
+ return reply(from, { type: MSG.ERROR, error: 'profile: ' + e.message })
1061
+ }
1062
+ }
1063
+
1064
+ async function handleStore (from, p) {
1065
+ const d = p.data
1066
+ // `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
1067
+ // `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
1068
+ // se llamaba como si fuera del store.
1069
+ if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
1070
+ // Con `code`: un aparato más nuevo que esta bóveda pide métodos que aquí no existen, y
1071
+ // tiene que poder decir «actualiza la bóveda» en vez de enseñar la frase.
1072
+ return reply(from, { type: MSG.ERROR, error: 'store: invalid method', code: 'store-unknown-method' })
1073
+ }
1074
+ if (!isFresh(d)) return staleReply(from)
1075
+ // EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
1076
+ // (`docs/datos-del-perfil.md`). Dos diferencias, y las dos son el arreglo:
1077
+ //
1078
+ // · **No hace falta abrir la bóveda.** Lo que llega es un sobre ya sellado y
1079
+ // firmado, que ella no puede leer ni falsificar: aceptarlo no es una decisión
1080
+ // suya. El candado estaba ahí porque guardaba el perfil en claro; ya no.
1081
+ // · **Lo gatea `firma`, no `guarda`.** Escribir tu identidad es hablar por ti, no
1082
+ // guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
1083
+ // para lo suyo y no debe poder cambiar quién eres.
1084
+ if (PROFILE_METHODS.has(d.method)) return await handleProfile(from, p, d)
1085
+ // EL ALMACÉN SOLO ENTRA CIFRADO. Lo que va por aquí —hilos de las apps, contraseñas,
1086
+ // el `me` heredado— viaja por el proxio, que no cifra; en claro lo leería quien lo
1087
+ // opere. Los clientes del ecosistema cifran desde @dotrino/identity 0.91.0, y hasta
1088
+ // entonces uno sin la clave de contenido mandaba en claro «como antes»: aceptarlo aquí
1089
+ // era dejar ese camino abierto para cualquier cliente viejo. El perfil (arriba) va
1090
+ // aparte a propósito: lo privado viaja en sobres y lo público es público.
1091
+ if (!d.enc) {
1092
+ audit('rejected', { what: 'store', method: d.method, reason: 'unsealed' })
1093
+ return reply(from, { type: MSG.ERROR, error: 'store: requests must be sealed with the profile content key', code: 'store-unsealed' })
1094
+ }
1095
+ // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
1096
+ // (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
1097
+ // leyendo y guardando contenido.
1098
+ if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
1099
+ audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
1100
+ return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
1101
+ }
1102
+ const revoked = await revocationSet()
1103
+ let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, ...(await contextoActa()), revoked })
1104
+ if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
1105
+ chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, ...(await contextoActa()), revoked })
1106
+ }
1107
+ if (!chk.ok) return denyChain(from, chk, p, 'store')
1108
+ // El scope que valió es el que hay que preguntarle al acta: un método de lectura pudo
1109
+ // pasar con `read` en vez de `store`, y exigirle `store` al acta lo rechazaría a pesar
1110
+ // de tener permiso para lo que pidió.
1111
+ if (!await actaAllows(from, chk, chk.scope === SCOPE.READ ? SCOPE.READ : SCOPE.STORE, 'store')) return
1112
+ try {
1113
+ // CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
1114
+ // pero no ve nada de lo que el usuario guarda. Se abre aquí con la clave de la bóveda
1115
+ // (que también es miembro) y la respuesta vuelve igual.
1116
+ const cek = await identity.contentKey()
1117
+ if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key', code: 'vault-no-content-key' })
1118
+ const args = JSON.parse(await identity.openContent(d.enc))
1119
+ const result = await threads.methods[d.method](args)
1120
+ // Que un aparato ESCRIBA en tu bóveda queda anotado. Antes solo se auditaba el
1121
+ // rechazo, así que la bitácora contaba quién entró pero no qué hizo después.
1122
+ // Solo la operación y el aparato: nunca el contenido (`activity` es un registro
1123
+ // de seguridad, no una copia de lo que guardas).
1124
+ if (!STORE_READ_METHODS.has(d.method)) {
1125
+ audit('store', { device: await deviceIdOf(chk.device), method: d.method })
1126
+ }
1127
+ const enc = await identity.sealContent(JSON.stringify(result ?? null))
1128
+ reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
1129
+ } catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
1130
+ }
1131
+
1132
+ // Lista (solo lectura) de dispositivos enrolados, para un panel en el navegador.
1133
+ // Cualquier cert válido tuyo puede verla; REVOCAR sigue siendo solo desde el PC.
1134
+ /**
1135
+ * Un aparato REVOCADO que vuelve a aparecer: se le reemite el `vault.revoked` FIRMADO
1136
+ * para que se entere y se auto-borre.
1137
+ *
1138
+ * Un «unauthorized: revoked» suelto no basta y no debe bastar: no va firmado, así que
1139
+ * el dispositivo tiene prohibido borrar nada con él (si no, cualquiera destruiría datos
1140
+ * ajenos con un mensaje). Lo único que puede borrar es un aviso firmado por la maestra
1141
+ * — y hasta ahora el daemon no lo reemitía nunca. Si el aparato estaba apagado cuando lo
1142
+ * quitaste, no se enteraba jamás: seguía enseñando el perfil como si nada.
1143
+ */
1144
+ async function notifyIfRevoked (pubkey, nonce = null, certIss = null, reason = 'revoked') {
1145
+ if (typeof pubkey !== 'string') return
1146
+ try {
1147
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1148
+ // Sin acta (bóveda anterior al acta) hay que mirar las delegaciones, que es lo único
1149
+ // que queda. OJO con dónde se buscan: desde identity 0.42 `issued` es «lo que HOY
1150
+ // sirve para entrar», así que los retirados NO están ahí — viven en `revokedCerts`.
1151
+ let knownRevoked = false
1152
+ let fallbackNonce = null
1153
+ if (!record) {
1154
+ const delegations = await identity.listDelegations()
1155
+ const revoked = await revocationSet()
1156
+ const candidates = [...(delegations.revokedCerts || []), ...(delegations.issued || [])]
1157
+ const theirs = candidates.filter((x) => x.sub === pubkey && (x.revokedAt || revoked.has(x.nonce)))
1158
+ knownRevoked = theirs.length > 0
1159
+ fallbackNonce = theirs[0]?.nonce || null
1160
+ }
1161
+ if (!shouldNotifyRevoked({ reason, pubkey, master, certIss, members: record?.members || null, knownRevoked })) return
1162
+ // El aviso nombra el certificado que el aparato ACABA de presentar: es el que tiene
1163
+ // en la mano, y es contra ese contra el que comprueba antes de borrarse.
1164
+ await desk.emitRevoke(pubkey, nonce || fallbackNonce)
1165
+ audit('revoke.notified', { device: await deviceIdOf(pubkey).catch(() => null), reason })
1166
+ } catch (e) { log('[vault] could not re-emit the revocation:', e.message) }
1167
+ }
1168
+
1169
+ /**
1170
+ * Rechaza una petición y, si el aparato ya no es del perfil, SE LO DICE con el aviso
1171
+ * firmado — sea cual sea la operación que estuviera intentando.
1172
+ *
1173
+ * Un dispositivo que fue tuyo tiene que poder llegar hasta aquí precisamente para que se
1174
+ * le pueda mandar a paseo: es el único mensaje que le borra la cuenta, porque es el único
1175
+ * que va firmado por la maestra (un «unauthorized» suelto no borra nada y no debe: sería
1176
+ * destruir datos ajenos con un mensaje, el wipe-DoS de `docs/pairing-protocol.md §2.3`).
1177
+ *
1178
+ * Antes esto solo pasaba en `devices` —la pantalla de dispositivos— y solo si el papel
1179
+ * estaba REVOCADO. Un aparato quitado que estuviera guardando notas nunca preguntaba por
1180
+ * ahí, y uno al que simplemente se le venció el certificado no entraba en el caso: los
1181
+ * dos se quedaban enseñando la cuenta indefinidamente.
1182
+ */
1183
+ async function denyChain (from, chk, p, what) {
1184
+ await notifyIfRevoked(p.data?.publickey, p.cert?.nonce || null, p.cert?.iss || null, chk.reason)
1185
+ if (what) audit('rejected', { what, reason: chk.reason })
1186
+ // `code` + `reason` como campos: quien llama decide por ellos, no por la frase.
1187
+ return reply(from, { type: MSG.ERROR, code: 'unauthorized', reason: chk.reason, error: 'unauthorized: ' + chk.reason })
1188
+ }
1189
+
1190
+ /**
1191
+ * «¿SIGO SIENDO DE ESTA CASA?» — la única pregunta que se atiende SIN certificado.
1192
+ *
1193
+ * Existe por el aparato que se quedó sin papel: no puede firmar, ni leer, ni renovar, y
1194
+ * —esto es lo grave— tampoco tenía forma de enterarse de que lo echaron, porque todo lo
1195
+ * demás exige el certificado que ya no tiene. Se quedaba enseñando para siempre una
1196
+ * cuenta que ya no era suya. Va firmada con la llave del propio aparato, que es
1197
+ * exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
1198
+ *
1199
+ * La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
1200
+ * pidió, y contarle el perfil entero a quien no trae papel es attach de más—. Al que ya no
1201
+ * está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
1202
+ * Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
1203
+ * nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
1204
+ * el aviso nombre al aparato que lo recibe).
1205
+ */
1206
+ // --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
1207
+ //
1208
+ // Los tres únicos mensajes que se atienden SIN certificado, porque quien pregunta todavía
1209
+ // no tiene llaves: están dentro del paquete que solo abre su contraseña. La bóveda no ve
1210
+ // la contraseña en ningún momento (OPAQUE), y responde igual exista o no el usuario.
1211
+ //
1212
+ // Funcionan con el perfil CERRADO: el paquete se devuelve tal cual, sin abrir nada. Lo que
1213
+ // sí exige la bóveda abierta es CREAR el aparato, porque eso sella el acta.
1214
+
1215
+ function loginError (from, e) {
1216
+ if (e?.code === 'too-many-tries') {
1217
+ return reply(from, { type: MSG.ERROR, error: e.message, code: e.code, waitMs: e.waitMs || 0 })
1218
+ }
1219
+ if (e?.code === 'login-failed' || e?.code === 'no-exchange' || e?.code === 'bad-input' || e?.code === 'bad-user') {
1220
+ return reply(from, { type: MSG.ERROR, error: e.message, code: e.code })
1221
+ }
1222
+ log('[vault] login: %s', e?.message || e)
1223
+ reply(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
1224
+ }
1225
+
1226
+ async function handleLoginStart (from, p) {
1227
+ try {
1228
+ const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
1229
+ audit('login.start', { user: String(p?.user || '').slice(0, 32) })
1230
+ reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
1231
+ } catch (e) { loginError(from, e) }
1232
+ }
1233
+
1234
+ async function handleLoginFinish (from, p) {
1235
+ try {
1236
+ const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
1237
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1238
+ audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
1239
+ log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
1240
+ reply(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || identity.me?.publickey || null, acta: record })
1241
+ } catch (e) { loginError(from, e) }
1242
+ }
1243
+
1244
+ /**
1245
+ * SALIR. Va firmado con la llave del propio aparato —la que acaba de abrir— porque cerrar
1246
+ * la sesión de otro sería echarlo de su cuenta.
1247
+ */
1248
+ async function handleLoginClose (from, p) {
1249
+ if (!isFresh(p?.data)) return staleReply(from)
1250
+ const d = p.data
1251
+ if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
1252
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
1253
+ }
1254
+ if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
1255
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
1256
+ }
1257
+ const mine = logins.list().find((x) => x.user === d.user)
1258
+ if (!mine || mine.pub !== d.publickey) {
1259
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
1260
+ }
1261
+ const r = logins.closeSession({ user: d.user, sid: d.sid })
1262
+ audit('login.close', { user: d.user, sid: d.sid.slice(0, 8), by: 'device' })
1263
+ reply(from, { type: MSG.LOGIN_CLOSED, ok: r.ok })
1264
+ }
1265
+
1266
+ async function handleCheck (from, p) {
1267
+ if (!isFresh(p?.data)) return staleReply(from)
1268
+ const pub = p.data.publickey
1269
+ if (typeof pub !== 'string') return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
1270
+ if (!(await verifyDeviceSig({ publickey: pub, data: p.data, signature: p.signature }))) {
1271
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
1272
+ }
1273
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1274
+ const inside = (record?.members || []).some((m) => m?.pub === pub)
1275
+ audit('check', { device: await deviceIdOf(pub).catch(() => null), in: inside })
1276
+ if (inside) return reply(from, { type: MSG.CHECKED, in: true })
1277
+ await notifyIfRevoked(pub, null, null, 'revoked')
1278
+ reply(from, { type: MSG.CHECKED, in: false })
1279
+ }
1280
+
1281
+ async function handleDevices (from, p) {
1282
+ if (!isFresh(p.data)) return staleReply(from)
1283
+ const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
1284
+ if (!chk.ok) return denyChain(from, chk, p, null)
1285
+ // DOS RESPUESTAS, PORQUE SON DOS PREGUNTAS DISTINTAS.
1286
+ //
1287
+ // Un SERVICIO pregunta aquí para enterarse de dos cosas suyas: si le revocaron el papel
1288
+ // y cuál es el acta vigente. Eso no es tu inventario y no puede exigir `lee` — se lo
1289
+ // exigí, y en producción dejó ciegos a los servicios (`rejected devices/acta` en la
1290
+ // bitácora del VPS, dos veces, antes de que lo viera).
1291
+ //
1292
+ // La LISTA DE APARATOS sí es el perfil —cómo se llama cada máquina y qué puede— y esa
1293
+ // sigue pidiendo `lee`. Antes no preguntaba nada, así que un aparato al que le quitaste
1294
+ // el permiso seguía viéndolo todo hasta que su papel caducara.
1295
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1296
+ // LA CADENA DE SELLADORES VIAJA CON LA LISTA, también para quien solo puede ver
1297
+ // revocaciones. Es lo que un aparato de Node necesita para FIRMAR a nombre de la cuenta
1298
+ // de forma que un extraño lo compruebe (`verifySignedFor` exige la cadena desde el
1299
+ // génesis), y hasta ahora ningún agente headless podía: solo el navegador la tenía. Es
1300
+ // corta a propósito (génesis, cambios de sellador, actual), así que no pasa por `fitChain`.
1301
+ const sealerChainOf = async () => { try { return (await identity.sealerChain?.()) || null } catch (e) { log('[vault] could not build the sealer chain:', e.message); return null } }
1302
+ const puedeVerTodo = !!record && Acta.memberCanScope(record, chk.device, SCOPE.READ)
1303
+ const { issued, revoked } = await identity.listDelegations()
1304
+ if (!puedeVerTodo) {
1305
+ if (!record) {
1306
+ audit('rejected', { what: 'devices', reason: 'sin-acta' })
1307
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
1308
+ }
1309
+ audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
1310
+ return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, blocked: blocked.pubs(), acta: record, sealerChain: await sealerChainOf() })
1311
+ }
1312
+ // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
1313
+ // política (quién manda, quién puede qué) sin un canal aparte.
1314
+ // Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
1315
+ // que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
1316
+ // de tragarse un salto a ciegas. Si se salió de la ventana, llega vacía y toca
1317
+ // re-emparejar — que es justo lo que debe pasar.
1318
+ // El `.catch()` de antes NO protegía de nada: si `actaHistory` no existe, el
1319
+ // TypeError es SÍNCRONO y salta antes de que haya promesa que encadenar. Pasó: el
1320
+ // método vivía en el núcleo pero no en el envoltorio de Node, así que esta línea
1321
+ // tiraba toda la respuesta y ningún navegador ya emparejado —que manda `sinceSeq`
1322
+ // siempre— volvía a sincronizar su acta. Un try/catch de verdad, y opcional.
1323
+ let chain = null
1324
+ if (typeof p.data?.sinceSeq === 'number') {
1325
+ try { chain = (await identity.actaHistory?.({ sinceSeq: p.data.sinceSeq }))?.chain || null }
1326
+ catch (e) { log('[vault] could not build the record chain:', e.message) }
1327
+ }
1328
+ // `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
1329
+ // proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
1330
+ // lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
1331
+ // QUÉ CORRE CADA UNO, y si cuadra con lo que corremos aquí (CONVENCIONES §14). Va con
1332
+ // la lista porque es donde se mira: el dueño lo pidió en el administrador Y en la
1333
+ // lista de dispositivos de la bóveda. `null` significa «todavía no ha hablado desde
1334
+ // que arrancamos», que es distinto de «no declara» — y se dice distinto.
1335
+ const devices = await Promise.all(issued.map(async (x) => ({
1336
+ deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
1337
+ running: x.sub ? versionDe(x.sub) : null,
1338
+ machine: x.sub ? machineOf(x.sub, record) : null,
1339
+ notifiedAt: x.sub ? notifiable.since(x.sub) : null
1340
+ })))
1341
+ // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1342
+ // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
1343
+ // Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
1344
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, blocked: blocked.pubs(), acta: record, chain, sealerChain: await sealerChainOf(), vault: MI_VERSION, subacta: subacta.entries() }))
1345
+ }
1346
+
1347
+ /**
1348
+ * NADA DE FRAMES QUE NO CABEN. El proxio corta a 1 MB y cierra la conexión con un 1009:
1349
+ * la respuesta no llega, la bóveda reconecta y no queda ni una línea de log de su lado.
1350
+ * Así estuvo muda tres días para todo el ecosistema (2026-08-24) mientras el bot social
1351
+ * repetía «no tienes ningún node de contenido enrolado».
1352
+ *
1353
+ * La cadena de actas es lo único que puede crecer sin techo aquí —cada acta es un
1354
+ * snapshot completo de los miembros—, así que se recorta por el FINAL: el tramo que sale
1355
+ * sigue siendo contiguo desde el `seq` del que pregunta, que es lo que necesita para
1356
+ * encadenar, y en la siguiente pregunta seguirá desde donde llegó.
1357
+ */
1358
+ function fitChain (msg) {
1359
+ const size = (m) => JSON.stringify(m).length
1360
+ if (size(msg) <= MAX_REPLY_BYTES) return msg
1361
+ const full = msg.chain?.length || 0
1362
+ while (msg.chain?.length && size(msg) > MAX_REPLY_BYTES) msg.chain = msg.chain.slice(0, -1)
1363
+ if (!msg.chain?.length) msg.chain = null
1364
+ const left = msg.chain?.length || 0
1365
+ log(`[vault] record chain trimmed to fit the transport: ${left}/${full} link(s), ${size(msg)} bytes`)
1366
+ // Si tras podar la cadena SIGUE sin caber, no se avisa y se manda igual —eso mataba la
1367
+ // conexión—: se devuelve tal cual y `reply` lo sustituye por un error. El aviso de aquí
1368
+ // sobra porque el de `reply` dice lo mismo y además dice qué hizo.
1369
+ return msg
1370
+ }
1371
+
1372
+ // RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
1373
+ // cert fresco (misma sub-clave y scope) sin QR ni aprobación — sigue siendo el
1374
+ // mismo dispositivo enrolado, solo extiende la ventana. Un cert vencido o
1375
+ // revocado NO puede renovarse (ahí sí toca re-emparejar con aprobación).
1376
+ async function handleRenew (from, p) {
1377
+ if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
1378
+ const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
1379
+ if (!chk.ok) return denyChain(from, chk, p, 'renew')
1380
+ // Reusar el label del cert original (si sigue registrado en delegations).
1381
+ const { issued } = await identity.listDelegations()
1382
+ const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
1383
+ // EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
1384
+ // decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
1385
+ // Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: attach
1386
+ // `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
1387
+ // QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
1388
+ // Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
1389
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1390
+ // Sin acta no se renueva: caer al scope del papel viejo es reconceder a ciegas lo que
1391
+ // el acta ya no dice. Un repliegue así no protege de nada, tapa.
1392
+ if (!record) {
1393
+ audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'sin-acta' })
1394
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
1395
+ }
1396
+ const scope = Acta.memberScopes(record, p.cert.sub)
1397
+ if (!scope.length) {
1398
+ audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
1399
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
1400
+ }
1401
+ // RENOVAR NO RETIRA EL PAPEL ANTERIOR (`supersede: false`), y esto costó dos servicios.
1402
+ //
1403
+ // Retirarlo al emitir parece limpio —un aparato, un papel— pero convierte cualquier
1404
+ // renovación que falle DESPUÉS de emitirse en una expulsión permanente: la máquina se
1405
+ // queda con el papel viejo, que acaba de quedar revocado, y ya no puede ni pedir otro.
1406
+ // Pasó dos veces en la migración del VPS (el registro de selladores y el bot social):
1407
+ // el papel nuevo salió, la respuesta no llegó a guardarse, y al reintentar la bóveda
1408
+ // contestaba `unauthorized: revoked`. Re-emparejar a mano, con TTY, por una renovación.
1409
+ //
1410
+ // Que convivan dos papeles del mismo aparato ya no es el problema que era: ninguno
1411
+ // vence, pero lo que pueden hacer lo decide el ACTA en cada mostrador, así que el
1412
+ // viejo no autoriza nada que el nuevo no autorice. Y quitar el aparato de verdad sigue
1413
+ // siendo `revokeDevice`, que los retira TODOS de una vez.
1414
+ const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '', supersede: false })
1415
+ audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope, seq: cert.seq })
1416
+ log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} · record #${cert.seq}`)
1417
+ // El acta viaja con el papel: sin ella quien lo recibe no puede comprobar que lo firmó
1418
+ // una selladora del perfil, que es lo que sustituyó a «lo firmó la maestra».
1419
+ reply(from, { type: MSG.RENEWED, cert, acta: record })
1420
+ }
1421
+
1422
+ // SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
1423
+ // pide el bundle de su namespace — el del SCOPE (que comparten todos los
1424
+ // aparatos que sirven ese ns) con el SUYO PROPIO encima (`secretsStore.js`).
1425
+ // Lo suyo se indexa por la llave que firma esta misma petición, así que no hay
1426
+ // manera de pedir lo de otro aparato. La respuesta va SELLADA a la llave ECDH
1427
+ // efímera `ek` que vino en el sobre firmado (el proxy transporta pero no
1428
+ // puede leer los valores) y el cuerpo va FIRMADO por la maestra (el
1429
+ // servicio verifica contra su iss pineada — un relay no puede inyectar
1430
+ // secretos falsos). Replay inerte: cada petición usa una ek nueva.
1431
+ // data: { op:'secrets', ns, ek, publickey, ts }
1432
+ /**
1433
+ * Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
1434
+ * suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
1435
+ * de variables va indexado por ella — se quedaría sin configuración sin decirlo.
1436
+ */
1437
+ async function handleEncKey (from, p) {
1438
+ const ns = p.data?.ns
1439
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
1440
+ const chk = await verifyChain({
1441
+ data: p.data, signature: p.signature, cert: p.cert,
1442
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
1443
+ })
1444
+ if (!chk.ok) return denyChain(from, chk, p, 'enckey')
1445
+ // MISMA FRONTERA QUE SERVIR EL CAJÓN, y por la misma razón: esto no solo apunta una
1446
+ // llave pública, acto seguido le ENVUELVE la del cajón (`spreadKey`, más abajo). Aquí
1447
+ // solo se miraba el certificado, así que a un aparato al que el acta ya le había
1448
+ // quitado `secrets` se le entregaba la llave igual, hasta 30 días. Es el mismo fallo
1449
+ // que se cerró en `handleSign`, en otro mostrador.
1450
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1451
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
1452
+ audit('rejected', { what: 'enckey', ns, reason: record ? 'cn' : 'sin-acta' })
1453
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
1454
+ }
1455
+ try {
1456
+ await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
1457
+ audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
1458
+ log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
1459
+ // Ya puede recibir sobres: se le envuelve la llave de sus cajones en el acto, o
1460
+ // seguiría sin poder abrir nada hasta la siguiente escritura.
1461
+ //
1462
+ // Va por `recipientsOf` —el único sitio que sabe quién debe tener envoltura de un
1463
+ // cajón—, que incluye a los SELLADORES. Con `nsMembers` se envolvía solo para los del
1464
+ // ns y los selladores se quedaban con lo viejo (dueño, 2026-09-01).
1465
+ //
1466
+ // Y su cajón PROPIO también: un aparato con variables suyas se quedaba sin poder
1467
+ // abrirlas, que es el mismo agujero por otra puerta.
1468
+ for (const owner of [`ns:${ns}`, `dev:${chk.device}`]) {
1469
+ await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }))
1470
+ .catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
1471
+ }
1472
+ // Con la llave de sellado, como todo lo que sirve la bóveda: la maestra no firma con la
1473
+ // bóveda cerrada, y esto tiene que funcionar cerrada.
1474
+ const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
1475
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1476
+ } catch (e) {
1477
+ reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * UN SERVICIO GUARDA VARIABLES EN SU PROPIO CAJÓN (dueño, 2026-09-29: «para facilitar la
1483
+ * migración de ENVs a Dotrino»). Es lo que usa `dotrino-env import`: el `.env` que ya
1484
+ * existe en la máquina del servicio sube tal cual, sin copiarlo a mano en la consola.
1485
+ *
1486
+ * Va por el canal del servicio —el mismo que lee el cajón— y con SUS fronteras: el cert
1487
+ * con `vault:secrets:<ns>` y el CN del acta. Así un servicio solo escribe en su cajón y
1488
+ * nunca en el de otro. La guarda la hace la MISMA pieza que la consola (`varsDesk`): la
1489
+ * firma del autor, las envolturas, la espera de aprobación. Aquí no hay otra puerta.
1490
+ *
1491
+ * Los límites (dueño, 2026-09-29):
1492
+ * · AÑADIR una variable nueva es directo, haya o no quien apruebe (dueño, 2026-10-12);
1493
+ * · EDITAR una que ya existe pide aprobación —`unattended` no exime—;
1494
+ * · si nadie puede aprobar, no se pide y lo que ya existe no se toca (`exists`);
1495
+ * · BORRAR, nunca: eso es de la consola y del CLI de la bóveda.
1496
+ *
1497
+ * Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
1498
+ * y `store` (los sobres ya hechos; la bóveda no los abre).
1499
+ */
1500
+ /**
1501
+ * LA HUELLA DE UN CAJÓN, tal como lo recibiría este aparato: nombres y GENERACIÓN de cada
1502
+ * variable, sin un solo valor. La visibilidad NO entra: taparla no cambia lo que el servicio
1503
+ * lee (y no sube la generación ni avisa). La generación la sube el almacén al
1504
+ * escribir, así que la huella cambia cuando cambia la configuración y no cuando la
1505
+ * bóveda solo rehace envolturas al abrirse. (Una rotación de llaves —al quitar un
1506
+ * aparato— sí la sube: el servicio se reinicia una vez, que es raro y no hace daño.)
1507
+ */
1508
+ function drawerDigest (ns, devicePub) {
1509
+ const b = secrets.bundleFor(ns, devicePub)
1510
+ const filas = Object.entries(b.entries || {})
1511
+ .map(([k, e]) => [k, b.legacy ? sha256Hex(String(e.v)) : (e.gen ?? null)])
1512
+ .sort((a, c) => (a[0] < c[0] ? -1 : 1))
1513
+ return sha256Hex(JSON.stringify(filas))
1514
+ }
1515
+
1516
+ /**
1517
+ * ¿CAMBIÓ MI CONFIGURACIÓN? Sin llevarse nada (dueño, 2026-09-30: «revisa que no se pidan
1518
+ * aprobaciones sin motivo»).
1519
+ *
1520
+ * El servicio lo pregunta al reconectarse, por si se perdió un aviso. Antes lo preguntaba
1521
+ * pidiendo el cajón ENTERO —privadas incluidas— para sacarle una huella, y eso es un
1522
+ * pedido de claves: a un servicio que pide aprobación le sonaba el teléfono al dueño en
1523
+ * cada despliegue del proxio, para aprobar algo que no entregaba nada que el servicio no
1524
+ * tuviera ya. La huella no revela nada, así que no se aprueba: quien pregunta tiene que
1525
+ * ser igualmente el servicio de ese cajón (cert + CN del acta).
1526
+ */
1527
+ async function handleDigest (from, p) {
1528
+ const ns = p.data?.ns
1529
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'digest: invalid namespace' })
1530
+ const chk = await verifyChain({
1531
+ data: p.data, signature: p.signature, cert: p.cert,
1532
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
1533
+ })
1534
+ if (!chk.ok) return denyChain(from, chk, p, 'digest')
1535
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1536
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
1537
+ audit('rejected', { what: 'digest', ns, reason: record ? 'cn' : 'sin-acta' })
1538
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
1539
+ }
1540
+ const body = { op: 'secrets.digest', ns, digest: drawerDigest(ns, chk.device), ts: Date.now() }
1541
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1542
+ }
1543
+
1544
+ /**
1545
+ * UN APARATO PREGUNTA SI PUEDE ACTUALIZARSE (`op: 'update-ask'`, dueño 2026-10-08).
1546
+ *
1547
+ * Pedir aprobación es un interruptor DE CADA AGENTE, independiente del de la bóveda
1548
+ * (`updateApproval` de `src/settings.js` vale solo para la propia bóveda): el agente solo
1549
+ * manda esto cuando lo tiene encendido. Así que aquí no se consulta ningún ajuste:
1550
+ *
1551
+ * · alguien aprueba en este perfil → un pedido `kind: 'update'` a nombre del aparato, que
1552
+ * dura un día; se contesta «pendiente» y el resultado sale cuando alguien decide, por
1553
+ * donde vino la pregunta. Vencido es no.
1554
+ * · nadie aprueba → sí, sin preguntar: esperar un sí que nadie puede dar sería no
1555
+ * actualizarse nunca.
1556
+ *
1557
+ * Lo puede preguntar cualquier miembro del acta: no pide nada que no sea suyo.
1558
+ */
1559
+ const updateAsks = new Map() // device|product|version → { id, dest } del pedido vivo
1560
+ const updateDecisions = new Map() // device|product|version → { ok: true, exp }: lo ya aprobado
1561
+ const cut = (v, n) => (typeof v === 'string' && v && v.length <= n ? v : null)
1562
+ async function handleUpdateAsk (from, p) {
1563
+ const chk = await verifyChain({
1564
+ data: p.data, signature: p.signature, cert: p.cert,
1565
+ ...(await contextoActa()), revoked: await revocationSet()
1566
+ })
1567
+ if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
1568
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1569
+ if (!record || !(record.members || []).some((m) => m.pub === chk.device)) {
1570
+ audit('rejected', { what: 'update-ask', reason: record ? 'acta' : 'sin-acta' })
1571
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1572
+ }
1573
+ const product = cut(p.data?.product, 128)
1574
+ const version = cut(p.data?.version, 64)
1575
+ const fromVersion = cut(p.data?.from, 64)
1576
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-ask: needs `product` and `version`' })
1577
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1578
+ const say = async (dest, extra) => {
1579
+ const body = { op: 'update-ask', product, version, ...extra, ts: Date.now() }
1580
+ reply(dest, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1581
+ }
1582
+
1583
+ const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
1584
+ if (!approvers) {
1585
+ audit('update-ask', { device: deviceId, product, version, asked: false })
1586
+ log(`[vault] update-ask: ${deviceId || '????-????'} may update ${product} to ${version} on its own (nobody approves in this profile)`)
1587
+ return say(from, { ok: true, asked: false })
1588
+ }
1589
+
1590
+ const key = `${chk.device}|${product}|${version}`
1591
+ const now = Date.now()
1592
+ for (const [k, d] of updateDecisions) if (d.exp <= now) updateDecisions.delete(k)
1593
+ // YA SE DECIDIÓ y quien preguntó no estaba para oírlo: se le dice lo decidido en vez de
1594
+ // volver a timbrar por lo mismo.
1595
+ const decided = updateDecisions.get(key)
1596
+ if (decided) return say(from, { ok: decided.ok, asked: true })
1597
+ // YA HAY UN PEDIDO VIVO por esto mismo (el agente se reinició y vuelve a preguntar): no se
1598
+ // timbra otra vez; la respuesta irá a quien pregunta ahora.
1599
+ const vivo = updateAsks.get(key)
1600
+ if (vivo && waiters.has(vivo.id)) {
1601
+ vivo.dest = from
1602
+ return say(from, { pending: true, id: vivo.id, exp: vivo.exp, asked: true })
1603
+ }
1604
+ const ask = { id: null, exp: null, dest: from }
1605
+ let pend
1606
+ try {
1607
+ pend = await awaitApproval({
1608
+ device: chk.device, ns: 'vault', kind: 'update', what: 'update',
1609
+ ctx: { product, version, from: fromVersion }, ttlMs: UPDATE_TTL_MS,
1610
+ onAnswer: async (ok) => {
1611
+ updateAsks.delete(key)
1612
+ // Solo se recuerda el SÍ. Un no puede ser un vencimiento (nadie lo vio), y
1613
+ // recordarlo dejaría al aparato un día sin poder volver a preguntar.
1614
+ if (ok === true) updateDecisions.set(key, { ok: true, exp: Date.now() + UPDATE_TTL_MS })
1615
+ await say(ask.dest, { ok: ok === true, asked: true, id: ask.id })
1616
+ }
1617
+ })
1618
+ } catch (e) {
1619
+ log(`[vault] update-ask: could not ask for approval: ${e.message}`)
1620
+ return reply(from, { type: MSG.ERROR, code: 'ask-failed', error: 'update-ask: could not ask for approval' })
1621
+ }
1622
+ ask.id = pend.id; ask.exp = pend.exp
1623
+ updateAsks.set(key, ask)
1624
+ return say(from, { pending: true, id: pend.id, exp: pend.exp, asked: true })
1625
+ }
1626
+
1627
+ /**
1628
+ * UN APARATO DICE QUE SE ACTUALIZÓ (`op: 'update-done'`): la bóveda se lo cuenta a quien
1629
+ * aprueba en este perfil, por el mismo camino que su propio «me actualicé». Si el agente
1630
+ * avisa o no lo decide él (su interruptor); aquí solo se reparte.
1631
+ *
1632
+ * Y su hermano `op: 'update-needs-root'`: el aparato vio una versión nueva y no puede
1633
+ * instalarla solo porque necesita permisos de administrador. Mismo reparto, otro `ev`.
1634
+ *
1635
+ * QUIÉN ES lo dice el acta (id y nombre), no lo que el aparato cuente de sí mismo.
1636
+ */
1637
+ async function handleUpdateDone (from, p) {
1638
+ const op = p.data.op
1639
+ const ev = op === 'update-needs-root' ? 'update-needs-root' : 'updated'
1640
+ const chk = await verifyChain({
1641
+ data: p.data, signature: p.signature, cert: p.cert,
1642
+ ...(await contextoActa()), revoked: await revocationSet()
1643
+ })
1644
+ if (!chk.ok) return denyChain(from, chk, p, op)
1645
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1646
+ const member = (record?.members || []).find((m) => m.pub === chk.device)
1647
+ if (!member) {
1648
+ audit('rejected', { what: op, reason: record ? 'acta' : 'sin-acta' })
1649
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1650
+ }
1651
+ const product = cut(p.data?.product, 128)
1652
+ const version = cut(p.data?.version, 64)
1653
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: `${op}: needs \`product\` and \`version\`` })
1654
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1655
+ try {
1656
+ await announceUpdate({ ev, version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
1657
+ } catch (e) {
1658
+ log(`[vault] ${op}: ${deviceId || '????-????'} reported ${product} ${version}, but the approvers could not be told: ${e.message}`)
1659
+ return reply(from, { type: MSG.ERROR, code: e.code || 'announce-failed', error: `${op}: the approvers could not be told` })
1660
+ }
1661
+ const body = { op, ok: true, product, version, ts: Date.now() }
1662
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1663
+ }
1664
+
1665
+ const storeNonces = new Map()
1666
+ async function handleVarStore (from, p) {
1667
+ const ns = p.data?.ns
1668
+ const op = p.data?.op
1669
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
1670
+ const chk = await verifyChain({
1671
+ data: p.data, signature: p.signature, cert: p.cert,
1672
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
1673
+ })
1674
+ if (!chk.ok) return denyChain(from, chk, p, op)
1675
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1676
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
1677
+ audit('rejected', { what: op, ns, reason: record ? 'cn' : 'sin-acta' })
1678
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
1679
+ }
1680
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1681
+ // FIRMADA COMO TODO LO QUE SIRVE LA BÓVEDA (con la llave de sellado que nombra el acta, y
1682
+ // el acta al lado): `store.recipients` dice PARA QUIÉN se sellan los valores, y si el
1683
+ // proxio pudiera cambiar esa lista por sus llaves, abriría las privadas al pasar.
1684
+ const answer = async (body) => reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1685
+ try {
1686
+ if (op === 'store.recipients') {
1687
+ return await answer({
1688
+ op: 'store.recipients', ns, ts: Date.now(),
1689
+ recipients: { public: await varsDesk.recipients({ ns, public: true }), private: await varsDesk.recipients({ ns, public: false }) },
1690
+ // Para que quien importa sepa de antemano qué va a pasar con lo que ya existe.
1691
+ approval: hasApprover(record)
1692
+ })
1693
+ }
1694
+ // CAMBIA ESTADO: un solo uso. La frescura (±5 min) no basta — repetir el mismo sobre
1695
+ // dentro de esa ventana volvería a timbrar el teléfono de quien aprueba.
1696
+ const nonce = p.data?.nonce
1697
+ if (typeof nonce !== 'string' || nonce.length < 16) return reply(from, { type: MSG.ERROR, code: 'no-nonce', error: 'store: missing single-use nonce' })
1698
+ const now = Date.now()
1699
+ for (const [n, exp] of storeNonces) if (exp <= now) storeNonces.delete(n)
1700
+ if (storeNonces.has(nonce)) { audit('rejected', { what: 'store', ns, reason: 'replay' }); return reply(from, { type: MSG.ERROR, code: 'replay', error: 'store: nonce already used' }) }
1701
+ storeNonces.set(nonce, now + 10 * 60 * 1000)
1702
+
1703
+ const items = p.data?.items
1704
+ if (!Array.isArray(items) || !items.length) return reply(from, { type: MSG.ERROR, error: 'store: needs `items`, each with its key and its sealed envelope' })
1705
+ const result = await varsDesk.setMany({ ns, items, caller: chk.device, by: deviceId, fromService: true })
1706
+ if (result?.pending) {
1707
+ audit('store.pending', { device: deviceId, ns, id: result.pending, count: items.length })
1708
+ log(`[vault] ${ns}: ${deviceId || '????-????'} wants to store ${items.length} variable(s) — waiting for approval (${result.pending})`)
1709
+ } else {
1710
+ audit('store', { device: deviceId, ns, keys: result?.keys || [] })
1711
+ log(`[vault] ${ns}: ${deviceId || '????-????'} stored ${(result?.keys || []).length} new variable(s)`)
1712
+ await notifyMembers('vars', { by: deviceId, keys: result?.keys || [], ns })
1713
+ }
1714
+ return await answer({ op: 'store.result', ns, ts: Date.now(), keys: result?.keys || [], pending: result?.pending || null })
1715
+ } catch (e) {
1716
+ return reply(from, { type: MSG.ERROR, ...(e.code ? { code: e.code } : {}), error: `${op}: ${e.message}` })
1717
+ }
1718
+ }
1719
+
1720
+ async function handleSecrets (from, p) {
1721
+ if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
1722
+ // REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
1723
+ // un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
1724
+ // de identidad y obligaría a re-vendorizar.
1725
+ //
1726
+ // No exige la contraseña del perfil, y el argumento importa: registrar una llave no
1727
+ // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
1728
+ // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
1729
+ if (p.data?.op === 'enckey') return handleEncKey(from, p)
1730
+ if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1731
+ if (p.data?.op === 'digest') return handleDigest(from, p)
1732
+ if (p.data?.op === 'update-ask') return handleUpdateAsk(from, p)
1733
+ if (p.data?.op === 'update-done' || p.data?.op === 'update-needs-root') return handleUpdateDone(from, p)
1734
+ if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
1735
+ const ns = p.data?.ns
1736
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
1737
+ if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
1738
+ const chk = await verifyChain({
1739
+ data: p.data, signature: p.signature, cert: p.cert,
1740
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
1741
+ })
1742
+ if (!chk.ok) return denyChain(from, chk, p, 'secrets')
1743
+ // FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
1744
+ // miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
1745
+ // llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
1746
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1747
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
1748
+ audit('rejected', { what: 'secrets', ns, reason: record ? 'cn' : 'sin-acta' })
1749
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
1750
+ }
1751
+ // APROBACIÓN: si este APARATO está marcado (`dotrino-vault approval <ID> on`), liberarle
1752
+ // claves privadas exige el visto bueno de un aparato con `approve` — en CADA petición,
1753
+ // que para un servicio bien hecho es una por arranque: pide al (re)iniciar, se queda las
1754
+ // claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
1755
+ // contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
1756
+ // (`handleApproval`), sellada a la misma `ek`.
1757
+ // SOLO PÚBLICAS: NO SE PIDE APROBACIÓN (dueño, 2026-09-01).
1758
+ //
1759
+ // La aprobación existe para soltar CLAVES PRIVADAS. Una variable pública está guardada
1760
+ // en claro —eso es lo que significa marcarla— y ya la ve cualquiera que administre, así
1761
+ // que hacer sonar el teléfono del dueño para entregarla es molestarlo por nada.
1762
+ //
1763
+ // Lo que NO se relaja: quien pide sigue teniendo que ser un miembro identificado, con su
1764
+ // papel y con el `cn` de este cajón (las dos comprobaciones de arriba). Y el filtro lo
1765
+ // hace la BÓVEDA (`bundleFor({ publicOnly })`), no el que pide: si mandáramos todo y el
1766
+ // cliente eligiera, pedir «solo públicas» sería la manera de saltarse la aprobación.
1767
+ const publicOnly = p.data?.publicOnly === true
1768
+ // Entregar SIN aprobación porque no hay quien apruebe es la regla (ver `needsApproval`),
1769
+ // pero se deja dicho: quien mire el log tiene que poder ver por qué no sonó el teléfono.
1770
+ if (!publicOnly && record && !Acta.memberCan(record, chk.device, 'unattended') &&
1771
+ !record.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))) {
1772
+ audit('secrets.no-approver', { device: await deviceIdOf(chk.device).catch(() => null), ns })
1773
+ log(`[vault] ${ns}: served ${await deviceIdOf(chk.device).catch(() => '????-????')} without approval — nobody in the record can approve`)
1774
+ }
1775
+ if (!publicOnly && needsApproval(chk.device, record)) {
1776
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1777
+ const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
1778
+ // QUÉ COMANDO, DESDE QUÉ CARPETA — medido contra `/proc` si se puede.
1779
+ let ctx = null
1780
+ try { ctx = contextoDelPedido(p.data?.ctx) } catch (e) {
1781
+ audit('rejected', { what: 'secrets', ns, reason: 'ctx-mismatch' })
1782
+ log(`[vault] ${ns}: refused ${deviceId || '????-????'} — ${e.message}`)
1783
+ return reply(from, { type: MSG.ERROR, code: 'ctx-mismatch', error: 'unauthorized: ' + e.message })
1784
+ }
1785
+ // ¿YA DIJISTE QUE SÍ A ESTE COMANDO? Entonces no se vuelve a timbrar: la concesión
1786
+ // dura una hora DESDE EL ÚLTIMO USO, así que un servicio que sigue pidiendo la
1787
+ // mantiene viva. Cambia el comando o la carpeta y esto ya no encaja: otro pedido.
1788
+ const vigente = grants.allows({ ns, device: chk.device, ctx })
1789
+ if (vigente) {
1790
+ try {
1791
+ const res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly: false })
1792
+ audit('secrets', { device: deviceId, ns, grant: vigente.id })
1793
+ log(`[vault] ${ns}: ${deviceId || '????-????'} served without ringing (approved command, ${vigente.uses} use(s))`)
1794
+ return reply(from, { type: MSG.SECRETS_RESULT, ...res })
1795
+ } catch (e) {
1796
+ return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
1797
+ }
1798
+ }
1799
+ // (Aquí se rechazaba con `no-approver` cuando nadie podía aprobar. Desde 2026-09-24 ese
1800
+ // caso ya no llega: sin aprobadores, `needsApproval` dice que no hace falta aprobación.)
1801
+ // `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
1802
+ // volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
1803
+ // proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
1804
+ // Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
1805
+ const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, ctx, from })
1806
+ audit('secrets.pending', { device: deviceId, ns, id: pend.id })
1807
+ log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})` +
1808
+ (ctx ? ` — ${commandLine(ctx, 80)} [${ctx.cwd || '?'}]` : ' — it does not say what it is running'))
1809
+ const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
1810
+ // El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
1811
+ // esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
1812
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record || null })
1813
+ await notifyApprovers(pend, record)
1814
+ return
1815
+ }
1816
+ let res
1817
+ try { res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly }) } catch (e) {
1818
+ return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
1819
+ }
1820
+ audit('secrets', { device: await deviceIdOf(chk.device), ns, ...(publicOnly ? { publicOnly: true } : {}) })
1821
+ reply(from, { type: MSG.SECRETS_RESULT, ...res })
1822
+ }
1823
+
1824
+ /** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la llave de sellado. */
1825
+ async function resultFor (ns, devicePub, ek, record, { publicOnly = false } = {}) {
1826
+ // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
1827
+ // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
1828
+ // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
1829
+ // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
1830
+ const b = secrets.bundleFor(ns, devicePub, { publicOnly })
1831
+ // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
1832
+ // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
1833
+ // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
1834
+ // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
1835
+ // de dónde salió lo que abre.
1836
+ // El acta va en LOS DOS caminos, también en el v3 heredado: ya no sirve solo para
1837
+ // decir de dónde salieron los sobres, sino para saber qué llave podía firmar esta
1838
+ // respuesta. Sin ella el agente no puede comprobar quién le contestó.
1839
+ const payload = b.legacy
1840
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])), acta: record || null }
1841
+ : { sealed: b, acta: record || null }
1842
+ // LO QUE SE MANDA CON AGUJEROS, SE DICE. La bóveda sabe aquí mismo que este aparato no
1843
+ // tiene envoltura de tal variable —`missingFor` lo contesta—, y hasta hoy se callaba:
1844
+ // contestaba un bundle que el agente no podía abrir y el registro decía «aprobado»,
1845
+ // como si se hubiera servido. Desde fuera parecía que el pedido funcionaba y volvía a
1846
+ // aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
1847
+ // bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
1848
+ if (!b.legacy && !publicOnly) {
1849
+ const faltan = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
1850
+ if (faltan.length) {
1851
+ const quien = await deviceIdOf(devicePub).catch(() => '????-????')
1852
+ audit('secrets.no-wrapping', { device: quien, ns, vars: faltan })
1853
+ log(`[vault] ${ns}: ${quien} has no wrapping for ${faltan.join(', ')} — open the vault (dotrino-vault unlock) so it hands it the key`)
1854
+ }
1855
+ }
1856
+ const enc = await seal({ ek, payload })
1857
+ // La huella va con cada entrega: es la referencia con la que el servicio compara después
1858
+ // (`digest`) sin volver a pedir claves.
1859
+ const body = { op: 'secrets.result', ns, enc, digest: drawerDigest(ns, devicePub), ts: Date.now() }
1860
+ // LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
1861
+ // no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
1862
+ // misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
1863
+ // bloqueado, y algún día desde una réplica que no tiene la maestra ni debe tenerla.
1864
+ return { body, seal: await sealOrFail(body) }
1865
+ }
1866
+
1867
+ /**
1868
+ * Firma con la llave de sellado, o revienta. No hay repliegue a la maestra: si el acta
1869
+ * no nombra una llave de sellado que sea nuestra, esta bóveda no está en condiciones de
1870
+ * servir, y decirlo es mejor que servir firmado por quien no toca.
1871
+ */
1872
+ async function sealOrFail (body) {
1873
+ const s = await signSeal(body)
1874
+ if (!s) throw new Error('this vault has no sealing key named by the record: it cannot serve')
1875
+ return s
1876
+ }
1877
+
1878
+ /**
1879
+ * PEDIDOS DE APROBACIÓN (aparatos marcados con `approval on`). Entran por `vault.secrets` con
1880
+ * `op: approvals | approve | deny`, firmados por un aparato al que el ACTA le da `approve`
1881
+ * — que, como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`).
1882
+ *
1883
+ * DECIDE EL ACTA, NO EL PAPEL (dueño, 2026-09-26). El papel solo prueba que el aparato es
1884
+ * de esta bóveda y no está revocado; por eso no se le pide `vault:approve`. Pedírselo
1885
+ * contestaba `unauthorized: scope` a un teléfono SIN el permiso, el teléfono lo tomaba por
1886
+ * un papel atrasado, pedía renovarlo, y renovar firma con la maestra: con la bóveda
1887
+ * cerrada le llegaba «vault locked», que no era la causa. Ahora le llega `acta`, que sí lo
1888
+ * es. Y a uno CON el permiso ya no le hace falta renovar para aprobar: funciona con la
1889
+ * bóveda cerrada, como todos los aparatos.
1890
+ */
1891
+ async function handleApproval (from, p) {
1892
+ const op = p.data?.op
1893
+ const chk = await verifyChain({
1894
+ data: p.data, signature: p.signature, cert: p.cert,
1895
+ ...(await contextoActa()), revoked: await revocationSet()
1896
+ })
1897
+ if (!chk.ok) return denyChain(from, chk, p, 'approval')
1898
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1899
+ if (!record || !Acta.memberCan(record, chk.device, 'approve')) {
1900
+ audit('rejected', { what: 'approval', reason: record ? 'acta' : 'sin-acta' })
1901
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member does not approve' })
1902
+ }
1903
+ const by = await deviceIdOf(chk.device).catch(() => null)
1904
+ const answer = async (body) => {
1905
+ body = { ...body, ts: Date.now() }
1906
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
1907
+ }
1908
+ // AL PEDIR SU LISTA, EL APROBADOR DICE SI LE LLEGAN LOS AVISOS (`notify`). Va dentro de lo
1909
+ // que ya firma, así que no hay mensaje nuevo ni nada que el proxio pueda inventar.
1910
+ if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
1911
+ try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
1912
+ }
1913
+ // `notices`: lo que no es un pedido y quien aprueba tiene que saber (la bóveda se
1914
+ // actualizó). El timbre de una app nativa llega vacío, así que lo lee de aquí.
1915
+ if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record), notices: notices.list() })
1916
+ // LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
1917
+ // renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
1918
+ // el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
1919
+ if (op === 'grants') return answer({ op: 'grants', items: await conContextoSellado(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
1920
+ // LOS BLOQUEADOS SE VEN desde donde se bloquean. Desbloquear no: eso es de la bóveda
1921
+ // (`dotrino-vault unblock <ID>`) o del administrador, que es lo que pidió el dueño.
1922
+ if (op === 'blocked') return answer({ op: 'blocked', items: blocked.list() })
1923
+ if (op === 'grant-revoke') {
1924
+ const gid = typeof p.data?.id === 'string' ? p.data.id : ''
1925
+ const ok = gid ? grants.revoke(gid) : false
1926
+ if (ok) {
1927
+ audit('secrets.grant.revoked', { id: gid, by })
1928
+ log(`[vault] grant ${gid} revoked by ${by} — that command will ring again`)
1929
+ }
1930
+ return answer({ op: 'grant-revoke.result', id: gid, ok })
1931
+ }
1932
+ const id = typeof p.data?.id === 'string' ? p.data.id : ''
1933
+ const pend = approvals.take(id)
1934
+ if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
1935
+ // UN INCIDENTE no entrega nada: se contesta BLOQUEAR (`block`) o IGNORAR (`deny`).
1936
+ // `approve` no significa nada aquí y se rechaza en voz alta: el pedido vuelve a la mesa
1937
+ // para que lo conteste con la palabra correcta.
1938
+ if (pend.kind === 'incident') {
1939
+ if (op !== 'block' && op !== 'deny') {
1940
+ approvals.request({ ...pend, ttlMs: Math.max(1000, pend.exp - Date.now()) })
1941
+ return reply(from, { type: MSG.ERROR, error: 'approval: an incident is answered with block or deny' })
1942
+ }
1943
+ if (op === 'deny') {
1944
+ audit('incident.ignored', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by })
1945
+ log(`[vault] incident ${id} about ${pend.deviceId} ignored by ${by}`)
1946
+ return answer({ op: 'deny.result', id, ok: true })
1947
+ }
1948
+ const b = blocked.block({ pub: pend.device, deviceId: pend.deviceId, label: pend.label, by, reason: pend.ctx?.kind || 'incident' })
1949
+ // Lo que ya tenía concedido se corta en el acto: bloquear y dejarle pasar la hora que
1950
+ // le quedaba sería un bloqueo de mentira.
1951
+ const cut = grants.revokeAll({ device: pend.device })
1952
+ audit('blocked', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by, grants: cut })
1953
+ log(`[vault] BLOCKED ${pend.deviceId}${pend.label ? ` (${pend.label})` : ''} by ${by} after incident ${id} · unblock with: dotrino-vault unblock ${pend.deviceId}`)
1954
+ // Se avisa a todos: los agentes (la terminal) traen la lista con el acta al recibirlo
1955
+ // y le cierran la sesión que tenga abierta. Sin esto el bloqueo esperaba al tic de 5 min.
1956
+ await notifyMembers('blocked', { deviceId: pend.deviceId, by })
1957
+ return answer({ op: 'block.result', id, ok: true, deviceId: pend.deviceId, since: b.since })
1958
+ }
1959
+ if (op === 'block') return reply(from, { type: MSG.ERROR, error: 'approval: only an incident can be blocked' })
1960
+ // PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
1961
+ // resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
1962
+ // que sellar, solo una promesa esperando un sí o un no.
1963
+ if (waiters.has(id)) {
1964
+ const resolver = waiters.get(id)
1965
+ waiters.delete(id)
1966
+ const ok = op === 'approve'
1967
+ const what = pend.kind === 'write' ? 'vars' : pend.kind === 'update' ? 'update' : 'passwords'
1968
+ audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
1969
+ log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1970
+ // Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
1971
+ // tiene que enterarse: decirle «hecho» a algo que no se guardó es el peor resultado.
1972
+ try { await resolver(ok) } catch (e) {
1973
+ log(`[vault] ${what}: ${pend.id} was approved but could not be applied: ${e.message}`)
1974
+ audit(`${what}.apply-failed`, { id, reason: e.message })
1975
+ return reply(from, { type: MSG.ERROR, error: `approval: approved, but it could not be applied: ${e.message}` })
1976
+ }
1977
+ return answer({ op: `${op}.result`, id, ok: true })
1978
+ }
1979
+ if (op === 'deny') {
1980
+ audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
1981
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
1982
+ try {
1983
+ const err = { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }
1984
+ const porLocal = pend.from && canales.get(pend.from)
1985
+ if (porLocal) porLocal(err)
1986
+ else client.sendByPubkey(pend.device, err)
1987
+ } catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
1988
+ return answer({ op: 'deny.result', id, ok: true })
1989
+ }
1990
+ if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
1991
+ let res
1992
+ try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
1993
+ return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
1994
+ }
1995
+ // LA HORA EMPIEZA AQUÍ, y se estira con cada uso: decir que sí a este comando vale para
1996
+ // las siguientes veces que lo pida ESE MISMO comando desde ESA MISMA carpeta. Un pedido
1997
+ // que no dijo qué corría no deja nada anotado, y se dice por qué: si no, el dueño vería
1998
+ // que unos pedidos dejan de timbrar y otros no, sin ninguna explicación a mano.
1999
+ const grant = grants.grant({ ns: pend.ns, device: pend.device, deviceId: pend.deviceId, ctx: pend.ctx, by })
2000
+ audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, ...(grant ? { grant: grant.id } : {}) })
2001
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
2002
+ log(grant
2003
+ ? `[vault] ${pend.ns}: "${commandLine(pend.ctx, 80)}" approved for ${Math.round(GRANT_TTL_MS / 60000)} min from its last use (grant ${grant.id})`
2004
+ : `[vault] ${pend.ns}: no grant — this request did not say what it is running, so the next one will ring again`)
2005
+ // Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
2006
+ // EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
2007
+ // «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
2008
+ // así que el dueño aprobaba una y otra vez creyendo que el teléfono no llegaba.
2009
+ // Por el canal por el que preguntó. Si fue el proxio, `sendByPubkey`: así lo recoge al
2010
+ // volver aunque ahora esté apagado. Si fue el socket local, por el socket.
2011
+ try {
2012
+ const porLocal = pend.from && canales.get(pend.from)
2013
+ if (porLocal) porLocal({ type: MSG.SECRETS_RESULT, ...res })
2014
+ else client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res })
2015
+ } catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
2016
+ return answer({ op: 'approve.result', id, ok: true })
2017
+ }
2018
+
2019
+ /**
2020
+ * EL COMANDO Y EL PATH VIAJAN DENTRO DE UN SOBRE, no en claro.
2021
+ *
2022
+ * La lista de pedidos sale hacia el teléfono por el proxio, que no cifra (CONVENCIONES
2023
+ * §4.1): mandar ahí el `argv` y el `cwd` le enseñaría al que opera el proxio las rutas del
2024
+ * disco del dueño y los argumentos de sus comandos, que es justo lo que este ecosistema
2025
+ * promete que no pasa. Así que el contexto se cifra **para el aparato que está
2026
+ * preguntando** —con la misma cripto de los secretos sellados: una llave de una vez,
2027
+ * envuelta a su `encPub` del acta— y él lo abre con `openSealedValue`, con una privada que
2028
+ * no sale de su dispositivo.
2029
+ *
2030
+ * Un aparato sin `encPub` en el acta no recibe el contexto, y se dice (`ctxSealed: false`)
2031
+ * en vez de mandárselo en claro «para que al menos lo vea»: eso sería cambiar la promesa
2032
+ * por comodidad, y encima sin avisar a nadie.
2033
+ */
2034
+ async function conContextoSellado (items, aprobador, record) {
2035
+ const encPub = (record?.members || []).find((m) => m.pub === aprobador)?.encPub || null
2036
+ const salida = []
2037
+ for (const it of items) {
2038
+ const { ctx, ...resto } = it
2039
+ if (!ctx) { salida.push({ ...resto, ctx: null }); continue }
2040
+ if (!encPub) { salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'no-enc-key' }); continue }
2041
+ try {
2042
+ const cek = await makeContentKey()
2043
+ const envelope = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(ctx) })
2044
+ const wrap = await wrapForMember({ cek, memberEncPub: encPub })
2045
+ salida.push({ ...resto, ctx: null, ctxSealed: true, ctxWrap: wrap, ctxEnvelope: envelope })
2046
+ } catch (e) {
2047
+ // NO SE TRAGA: sin esto, un fallo al sellar se vería en el teléfono igual que un
2048
+ // pedido que no dice nada, y nadie sabría que hay algo que arreglar.
2049
+ log(`[vault] could not seal the command of ${it.id} for ${aprobador.slice(0, 24)}…: ${e.message}`)
2050
+ salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'seal-failed' })
2051
+ }
2052
+ }
2053
+ return salida
2054
+ }
2055
+
2056
+ /**
2057
+ * Aviso a los aparatos que aprueban (cola del proxio → timbre nativo si están apagados).
2058
+ *
2059
+ * LA MAESTRA NO FIRMA ESTO, y es el fallo que dejaba el mecanismo entero inservible.
2060
+ * Avisar es SERVIR, y servir es trabajo de la llave de sellado —la maestra solo sella el
2061
+ * acta y reenvuelve los sobres al abrir (CLAUDE.md, «la maestra tiene DOS trabajos»)—.
2062
+ * Con `identity.signData` esto reventaba con «vault locked» en cada pedido, o sea SIEMPRE:
2063
+ * la bóveda vive cerrada, que es justamente para lo que sirve el candado. Y como reventaba
2064
+ * antes de mandar nada, el proxio no tenía a quién encolar y el teléfono NO TIMBRABA
2065
+ * NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
2066
+ *
2067
+ * El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
2068
+ * sin contenido: quien lo recibe pregunta por la lista, no la lee del aviso.
2069
+ */
2070
+ async function notifyApprovers (pend, record) {
2071
+ try {
2072
+ // `kind` dice QUÉ se pide (llevarse claves, guardar variables, actualizar…): es lo que la
2073
+ // app necesita para que el aviso diga el porqué sin tener que abrir la lista. Los
2074
+ // detalles (comando, carpeta, nombres) NO van aquí: van sellados en la lista.
2075
+ const body = { ev: 'approval', id: pend.id, ns: pend.ns, kind: pend.kind || 'read', deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
2076
+ const seal = await sealOrFail(body)
2077
+ const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
2078
+ if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
2079
+ // EL FALLO NO SE TRAGA. Un aviso que no sale es un teléfono que no timbra, y eso
2080
+ // desde fuera se ve igual que «nadie ha aprobado todavía»: se espera para siempre
2081
+ // sin nada que mirar. Es el mismo cuidado que en `notifyMembers`.
2082
+ for (const pub of who) {
2083
+ // A la app que APRUEBA (`vault`): en un teléfono la llave es de todas sus apps, y sin
2084
+ // decirlo el pedido timbraba a messenger (websocket-proxy ≥ 1.4.0).
2085
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }) }
2086
+ catch (e) { log(`[vault] could not ring ${pub.slice(0, 24)}… about ${pend.ns}: ${e.message}`) }
2087
+ }
2088
+ log(`[vault] ${pend.ns}: rang ${who.length} approver(s) for ${pend.id}`)
2089
+ } catch (e) { log('[vault] could not notify approvers:', e.message) }
2090
+ }
2091
+
2092
+ /**
2093
+ * «ME ACTUALICÉ», a todo el que aprueba (dueño, 2026-10-08). Vale con la aprobación
2094
+ * encendida o apagada: lo que se instala solo también se cuenta.
2095
+ *
2096
+ * Lo manda la versión NUEVA ya corriendo —el daemon llama aquí al arrancar, si dejó dicho
2097
+ * que instaló—, que es la prueba de que ocurrió. El aviso queda además en `notices`, de
2098
+ * donde lo lee una app nativa (su timbre llega vacío).
2099
+ *
2100
+ * También reparte el «me actualicé» de un APARATO (`op: 'update-done'`): entonces lleva su
2101
+ * `product`, `deviceId` y `label`.
2102
+ *
2103
+ * Y con `ev: 'update-needs-root'` cuenta lo contrario: hay versión nueva y NO se instaló
2104
+ * sola porque necesita permisos de administrador (dueño, 2026-10-08: «si se requiere root
2105
+ * por algún motivo, simplemente se notifica al aprobador»). Mismo camino, otro `ev`.
2106
+ *
2107
+ * A diferencia de `notifyApprovers`, aquí el fallo SE LANZA: quien llama guarda el
2108
+ * marcador hasta que el aviso salió, y tragarlo sería borrarlo sin haber avisado.
2109
+ * @returns {Promise<{ approvers: number, sent: number, notice: object|null }>}
2110
+ */
2111
+ async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null, ev = 'updated' }) {
2112
+ const record = await refreshActa()
2113
+ const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
2114
+ if (!who.length) return { approvers: 0, sent: 0, notice: null }
2115
+ // `deviceId`/`label`: solo cuando quien se actualizó es un APARATO (`update-done`). El
2116
+ // aviso de la propia bóveda no los lleva.
2117
+ const notice = notices.add(ev, { version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
2118
+ const { id, ev: _ev, ts, ...rest } = notice
2119
+ const body = { ev, id, ...rest, ts }
2120
+ const seal = await sealOrFail(body)
2121
+ let sent = 0
2122
+ for (const pub of who) {
2123
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }); sent++ }
2124
+ catch (e) { log(`[vault] could not tell ${pub.slice(0, 24)}… about the update of ${product} to ${version}: ${e.message}`) }
2125
+ }
2126
+ audit(ev === 'updated' ? 'update.announced' : 'update.needs-root', { product, version, from: notice.from, device: deviceId, approvers: who.length, sent })
2127
+ log(`[vault] update: told ${sent} of ${who.length} approver(s) that ${deviceId ? `${deviceId} (${product})` : 'this vault'} ${ev === 'updated' ? 'now runs' : 'needs administrator rights to install'} ${version}`)
2128
+ if (!sent) throw Object.assign(new Error('no approver could be told'), { code: 'announce-failed' })
2129
+ return { approvers: who.length, sent, notice }
2130
+ }
2131
+
2132
+ /**
2133
+ * UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
2134
+ * clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
2135
+ * nada: se apunta como un pedido más, de tipo `incident`, y se timbra a quien aprueba,
2136
+ * que es quien bloquea o ignora desde el teléfono. Sin nadie que apruebe no hay a quién
2137
+ * preguntar: se anota en la bitácora y se dice, y el agente se queda con su freno local.
2138
+ *
2139
+ * Quien reporta tiene que ser un miembro con `sign` (es lo que lo hace agente), y el
2140
+ * señalado tiene que estar en el acta: un incidente sobre un desconocido no es un incidente.
2141
+ * Y si el señalado ya está bloqueado no se vuelve a timbrar: se contesta que lo está.
2142
+ */
2143
+ async function handleIncident (from, p) {
2144
+ if (!isFresh(p.data)) return staleReply(from)
2145
+ const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
2146
+ if (!chk.ok) return denyChain(from, chk, p, 'incident')
2147
+ const record = await refreshActa()
2148
+ if (!record || !Acta.memberCan(record, chk.device, 'sign')) {
2149
+ audit('rejected', { what: 'incident', reason: record ? 'acta' : 'sin-acta' })
2150
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member cannot report incidents' })
2151
+ }
2152
+ const d = p.data || {}
2153
+ const kind = typeof d.kind === 'string' ? d.kind.slice(0, 32) : ''
2154
+ const about = typeof d.about === 'string' ? d.about : ''
2155
+ const tries = Number.isInteger(d.tries) ? d.tries : null
2156
+ if (d.op !== 'incident' || !kind || !about) return reply(from, { type: MSG.ERROR, error: 'incident: op, kind and about are required' })
2157
+ const target = (record.members || []).find((m) => m.pub === about)
2158
+ if (!target) return reply(from, { type: MSG.ERROR, code: 'unknown-device', error: 'incident: that device is not in the record' })
2159
+ const by = await deviceIdOf(chk.device).catch(() => null)
2160
+ const byLabel = (record.members || []).find((m) => m.pub === chk.device)?.label || ''
2161
+ const deviceId = await deviceIdOf(about).catch(() => null)
2162
+ audit('incident', { device: deviceId, by, kind, tries })
2163
+ if (blocked.has(about)) {
2164
+ log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: already blocked`)
2165
+ return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: true })
2166
+ }
2167
+ const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
2168
+ if (!approvers) {
2169
+ log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: nobody can approve, so nobody is asked (grant it with: dotrino-vault caps <ID> +aprueba)`)
2170
+ return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: false })
2171
+ }
2172
+ // `ns` es el que reporta: la pantalla dice «X falló la clave de la terminal de Y». El
2173
+ // contexto va sellado al aprobador como el de cualquier pedido.
2174
+ const pend = approvals.request({
2175
+ ns: by || 'incident', kind: 'incident', device: about, deviceId, label: target.label || '', ek: '',
2176
+ ctx: { kind, tries, reporter: by, reporterLabel: byLabel }, from, ttlMs: UPDATE_TTL_MS
2177
+ })
2178
+ log(`[vault] incident (${kind}) about ${deviceId}${target.label ? ` (${target.label})` : ''} reported by ${by}: asking ${approvers} approver(s) (${pend.id})`)
2179
+ await notifyApprovers(pend, record)
2180
+ reply(from, { type: MSG.INCIDENT_RESULT, id: pend.id, approvers, blocked: false })
2181
+ }
2182
+
2183
+ client.on('message', (from, payload) => onMessage(from, payload))
2184
+
2185
+ // EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
2186
+ // de archivo, un directorio de solo lectura) se dice y se sigue: los servicios usan el
2187
+ // proxio como hasta ahora. Lo que NO se hace es callarlo — un atajo que no está y nadie
2188
+ // lo dice es media hora buscando por qué algo va lento.
2189
+ // El socket se nombra por la LLAVE de esta bóveda: una máquina puede tener varias, y un
2190
+ // servicio solo conoce la maestra que lleva pineada. Así los dos lados calculan la misma
2191
+ // ruta sin mirar ningún índice.
2192
+ if (identity.me?.publickey && typeof platform.openLocalDesk === 'function') {
2193
+ platform.openLocalDesk({
2194
+ name: (await pubkeyId(identity.me.publickey)).slice(0, 16),
2195
+ onMessage, channels: canales, maxBytes: MAX_REPLY_BYTES, log
2196
+ })
2197
+ }
2198
+
2199
+ async function onMessage (from, payload) {
2200
+ if (!payload || typeof payload !== 'object') return
2201
+ try {
2202
+ if (payload.type === MSG.HELLO) return desk.handleHello(from, payload)
2203
+ if (payload.type === MSG.ENROLL) return await desk.handleEnroll(from, payload)
2204
+ if (payload.type === MSG.ACTA_SEALED) return await desk.handleActaSealed(from, payload)
2205
+ if (payload.type === MSG.SIGN) return await handleSign(from, payload)
2206
+ if (payload.type === MSG.GET) return await handleGet(from, payload)
2207
+ if (payload.type === MSG.STORE) return await handleStore(from, payload)
2208
+ if (payload.type === MSG.CHECK) return await handleCheck(from, payload)
2209
+ if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
2210
+ if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
2211
+ if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
2212
+ if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
2213
+ if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
2214
+ if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
2215
+ if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
2216
+ if (payload.type === MSG.INCIDENT) return await handleIncident(from, payload)
2217
+ if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
2218
+ if (payload.type === MSG.LOGIN_START) return await handleLoginStart(from, payload)
2219
+ if (payload.type === MSG.LOGIN_FINISH) return await handleLoginFinish(from, payload)
2220
+ if (payload.type === MSG.LOGIN_CLOSE) return await handleLoginClose(from, payload)
2221
+ } catch (e) {
2222
+ reply(from, { type: MSG.ERROR, error: e.message })
2223
+ }
2224
+ }
2225
+
2226
+ /**
2227
+ * AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
2228
+ * ADOPTA, que es lo que hace que conceder un permiso surta efecto en la otra máquina en
2229
+ * vez de esperar a que renueve su cert.
2230
+ *
2231
+ * No se comprueba quién lo manda y no hace falta: `adoptActa` aplica §2.4.1 —firma,
2232
+ * encadenado, `seq` que no baja y desempate— así que un acta ajena o vieja se rechaza
2233
+ * sola. Fiarse del remitente sería la comprobación débil; fiarse del acta es la fuerte.
2234
+ */
2235
+ async function handleAdminEvent (payload) {
2236
+ const acta = payload?.acta
2237
+ if (!acta || typeof acta !== 'object') return
2238
+ // ESTE MENSAJE NO LO FIRMA NADIE, y no hace falta: el acta se autentica sola —va sellada
2239
+ // y `canAdopt` comprueba que la selló quien la MÍA reconoce—. Pero eso solo funciona si
2240
+ // hay una mía contra la que comparar: sin acta previa, `canAdopt` acepta cualquiera
2241
+ // («sin-acta-previa»), y este es el único mostrador donde una llega de un desconocido.
2242
+ // Anunciar no es cómo se entra en una cuenta —para eso está el enrolamiento—, así que
2243
+ // sin acta propia no se adopta: se dice y se para.
2244
+ const propia = (await identity.profileActa?.().catch(() => null))?.acta || null
2245
+ if (!propia) return log('[vault] a record was announced but this vault has none of its own to compare it with: ignored')
2246
+ try {
2247
+ const r = await identity.adoptActa?.(acta)
2248
+ if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)
2249
+ // Y el RECHAZO también se dice. Callarlo dejaría el peor fallo de todos: se concede
2250
+ // un permiso en una máquina, la otra no se entera, y no hay una sola línea que lo
2251
+ // explique — que es exactamente lo que costó encontrar esto.
2252
+ else log(`[vault] IGNORED the record #${acta.seq} announced by another vault: ${r?.reason || 'no reason given'}`)
2253
+ } catch (e) { log('[vault] could not adopt the announced record:', e.message) }
2254
+ }
2255
+
2256
+ // ----- LA BÓVEDA DE CONTRASEÑAS -----
2257
+ //
2258
+ // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
2259
+ // la CA. Solo se levanta cuando el acta autoriza a alguien — sin eso no hay a quién
2260
+ // responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
2261
+ //
2262
+ // Y se levanta EN CUANTO pasa, no en el siguiente arranque: conceder el permiso ya es
2263
+ // un acto explícito del dueño, y pedirle además que reinicie la bóveda es de las cosas
2264
+ // que se olvidan y luego parecen un fallo del gestor.
2265
+ let passwords = null
2266
+ /**
2267
+ * EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
2268
+ * (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
2269
+ *
2270
+ * Lo arma el PILAR (`identitySealing`), que es la MISMA pieza que usan las otras dos
2271
+ * bóvedas —la de la pestaña y la de dentro de la extensión—. Estaba escrito a mano aquí,
2272
+ * y son las tres puntas del mismo sobre: tres copias son dos que se pueden quedar atrás,
2273
+ * y ese fallo no hace ruido — la petición sale, al otro lado «no es para mí», y desde
2274
+ * fuera se ve como que nadie respondió.
2275
+ *
2276
+ * NO se pone `requireSealed` en el cliente, y no es un olvido: el cliente del vault es
2277
+ * UNO y lo comparte todo, y el protocolo de la CA viaja en claro a propósito (un
2278
+ * enrolamiento es público hasta que hay cert). Lo que corta aquí es `VaultResponder`,
2279
+ * que tira lo que llegue sin la marca `sealed`. Por eso `isSealed` mira la marca del
2280
+ * gestor y no otra.
2281
+ *
2282
+ * Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
2283
+ * abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
2284
+ */
2285
+ const passwordSealing = identitySealing(identity)
2286
+
2287
+ /**
2288
+ * LA POLÍTICA DE LAS CONTRASEÑAS, una sola para las tres bóvedas. La usa el mostrador de
2289
+ * aquí y la usa la extensión, que atiende su propio almacén pero decide con esto.
2290
+ *
2291
+ * EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en el
2292
+ * acto. Con el caché, la mesa seguía decidiendo con la foto del último refresco — y en el
2293
+ * sentido peligroso: un aparato al que le acabas de quitar el permiso se seguía sirviendo
2294
+ * solo.
2295
+ */
2296
+ async function passwordsNeedApproval (pub) { return needsApproval(pub, await refreshActa()) }
2297
+
2298
+ /**
2299
+ * El teléfono: se apunta el pedido, se le avisa y la promesa espera su firma. Es el mismo
2300
+ * camino que ya recorren los cajones de secretos.
2301
+ *
2302
+ * EL PEDIDO DICE QUÉ SE QUIERE LEER (dueño, 2026-09-30: «es importante que se sepa el
2303
+ * porqué»): `kind: 'passwords'` con la operación y los CAMPOS (`password`, `totp`…) —
2304
+ * nunca la entrada ni el sitio, que no se ven aquí. Va sellado al que aprueba.
2305
+ */
2306
+ async function approvePasswordRequest ({ pubkey, op, payload }) {
2307
+ let resolve
2308
+ const espera = new Promise((r) => { resolve = r })
2309
+ const fields = Array.isArray(payload?.keys) ? payload.keys.slice(0, 12).map((k) => String(k).slice(0, 40)) : null
2310
+ await awaitApproval({
2311
+ device: pubkey, ns: 'passwords', kind: 'passwords', what: 'passwords', op,
2312
+ ctx: { op: String(op || '').replace(/^pm2\./, ''), fields }, onAnswer: resolve
2313
+ })
2314
+ return espera
2315
+ }
2316
+
2317
+ const startPasswordDesk = async () => {
2318
+ // EL ALMACÉN LO ATIENDE QUIEN MONTA LA BÓVEDA (la extensión: su worker es el único que
2319
+ // escribe sus contraseñas, y dos escritores se pisarían). La política sigue siendo esta.
2320
+ if (platform.servePasswords === false) return
2321
+ if (passwords || !passwordDevices().length) return
2322
+ const { createPasswordDesk } = await import('./passwords.js')
2323
+ client.updateConfig({ sealing: passwordSealing })
2324
+ passwords = createPasswordDesk({
2325
+ client,
2326
+ store: passwordsStore,
2327
+ // LA BÓVEDA NO TIENE LLAVE. Lo que pone aquí es a quién envolverle las que hacen los
2328
+ // aparatos, y la pública de la copia de recuperación —la que abre la frase del perfil
2329
+ // y es la única forma de volver a repartir una entrada si un día no queda ningún
2330
+ // aparato—. Ninguna de las dos abre nada por sí sola.
2331
+ members: (kind) => passwordRecipients(kind),
2332
+ recoveryPub: () => secrets.recoveryPub(),
2333
+ canWrite: (pub) => !!actaCache && !!pub && Acta.memberCan(actaCache, pub, 'passwords'),
2334
+ // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
2335
+ // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
2336
+ // que haya una segunda lista que acordarse de tocar.
2337
+ isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
2338
+ encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
2339
+ // EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
2340
+ // el acto. Con el caché, la mesa de contraseñas seguía decidiendo con la foto del
2341
+ // último refresco — y en el sentido peligroso: un aparato al que le acabas de quitar
2342
+ // el permiso se seguía sirviendo solo.
2343
+ needsApproval: passwordsNeedApproval,
2344
+ approve: approvePasswordRequest,
2345
+ audit,
2346
+ log,
2347
+ }).start()
2348
+ log(`[vault] passwords: serving ${passwordDevices().length} device(s)`)
2349
+ }
2350
+ try {
2351
+ // El acta ANTES de mirarla: es ella la que dice si hay a quién responder, y sin
2352
+ // refrescarla primero el mostrador no se levantaba nunca en un arranque en frío.
2353
+ await refreshActa()
2354
+ const actaSweeper = setInterval(() => {
2355
+ refreshActa().then(startPasswordDesk).catch(() => {})
2356
+ }, 5000)
2357
+ actaSweeper.unref?.()
2358
+ if (passwordDevices().length) await startPasswordDesk()
2359
+ // Ni código de enlace ni pegar nada: un gestor entra como cualquier otro aparato.
2360
+ else log('[vault] passwords: no device may ask yet · pair one with `dotrino-vault pair --scope contrasenas`')
2361
+ } catch (e) {
2362
+ log(`[vault] passwords: could not start (${e.message}); everything else keeps going`)
2363
+ }
2364
+
2365
+ log(`[vault] listo · id ${fp} · ${store.getTree().children.length} nodos`)
2366
+
2367
+ // ----- API local (CLI/UI de control) -----
2368
+ // Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
2369
+
2370
+ // ----- AVISO DE CAMBIO a los agentes del ns -----
2371
+ //
2372
+ // Guardar un secreto no sirve de nada si quien lo usa no se entera. La bóveda
2373
+ // avisa (sin mandar valores: solo «el ns cambió») y el agente decide — el
2374
+ // estándar es que SALGA y lo levante su supervisor, para leer todo fresco y,
2375
+ // sobre todo, para que el valor viejo deje de existir en su memoria.
2376
+ //
2377
+ // AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
2378
+ // escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
2379
+ // reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
2380
+ // La variable de entorno va en inglés (CONVENCIONES §8.1); nadie la tenía puesta.
2381
+ const NOTICE_GROUP_MS = Number(platform.noticeGroupMs) || 3000
2382
+ const pendingNotices = new Map() // clave (ns | 'dev:'+pub) → timer
2383
+
2384
+ async function notifyNsChange (ns) {
2385
+ let targets = []
2386
+ try {
2387
+ const { issued } = await identity.listDelegations()
2388
+ const revokedNonces = await revocationSet()
2389
+ const scope = secretsScope(ns)
2390
+ // Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
2391
+ // que mandárselo a otro sería filtrarle que existe.
2392
+ //
2393
+ // Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
2394
+ // delegación nueva para la MISMA sub-clave, así que un agente que lleve
2395
+ // tiempo enrolado aparece varias veces y recibiría el aviso repetido.
2396
+ const seen = new Set()
2397
+ targets = (issued || []).filter((x) => {
2398
+ if (!x.sub || revokedNonces.has(x.nonce) || !(x.scope || []).includes(scope)) return false
2399
+ if (seen.has(x.sub)) return false
2400
+ seen.add(x.sub)
2401
+ return true
2402
+ })
2403
+ } catch (e) { return log('[vault] could not list who to notify:', e.message) }
2404
+ if (!targets.length) return
2405
+
2406
+ // FIRMA LA LLAVE DE SELLADO, con el acta al lado (el servicio lo comprueba con
2407
+ // `verifyResponder`). Firmaba la maestra, y con la bóveda cerrada —que es como vive— el
2408
+ // aviso reventaba antes de salir: el servicio no se enteraba de ningún cambio.
2409
+ const body = { op: 'secrets.changed', ns, ts: Date.now() }
2410
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
2411
+ const seal = await sealOrFail(body)
2412
+ for (const d of targets) {
2413
+ try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, seal, acta }) } catch (_) {}
2414
+ }
2415
+ audit('secrets.changed', { ns, notified: targets.length })
2416
+ log(`[vault] config for "${ns}" changed: notified ${targets.length} agent(s)`)
2417
+ }
2418
+
2419
+ /**
2420
+ * Cambió una variable de UN aparato: el aviso va solo a ese aparato. El mensaje dice
2421
+ * qué NAMESPACE cambió (es lo que el agente sabe leer), así que hace falta su `cn`; un
2422
+ * miembro sin `cn` no es un servicio y no lee variables, de modo que no hay a quién
2423
+ * avisar y no se manda nada.
2424
+ */
2425
+ async function notifyDeviceChange (pub) {
2426
+ let cn = null
2427
+ let record = null
2428
+ try {
2429
+ record = (await identity.profileActa?.().catch(() => null))?.acta
2430
+ cn = (record?.members || []).find((m) => m.pub === pub)?.cn || null
2431
+ } catch (e) { return log('[vault] could not look up who to notify:', e.message) }
2432
+ if (!cn) return
2433
+ const body = { op: 'secrets.changed', ns: cn, ts: Date.now() }
2434
+ const seal = await sealOrFail(body)
2435
+ try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body, seal, acta: record }) } catch (_) {}
2436
+ const device = await deviceIdOf(pub).catch(() => null)
2437
+ audit('secrets.changed', { ns: cn, device, notified: 1 })
2438
+ log(`[vault] config for device ${device} ("${cn}") changed: notified it`)
2439
+ }
2440
+
2441
+ /**
2442
+ * Un cambio de configuración, un aviso: escrituras seguidas se agrupan en la misma
2443
+ * ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
2444
+ * lo de un aparato no cancele el aviso pendiente de todo su namespace.
2445
+ */
2446
+ function scheduleNotice (ns) {
2447
+ schedule(ns, async () => {
2448
+ await notifyNsChange(ns)
2449
+ // Y a los replicadores el sobre nuevo. Sin esto repartirían el valor de ayer: el
2450
+ // aviso de cambio le dice al servicio que se reinicie, y al reiniciar volvería a
2451
+ // pedir — y si la bóveda está apagada, el replicador le daría lo viejo.
2452
+ await pushToReplicas(`secret:${ns}`)
2453
+ })
2454
+ }
2455
+ function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
2456
+
2457
+ function schedule (key, fn) {
2458
+ clearTimeout(pendingNotices.get(key))
2459
+ const t = setTimeout(() => {
2460
+ pendingNotices.delete(key)
2461
+ fn().catch((e) => log('[vault] change notice failed:', e.message))
2462
+ }, NOTICE_GROUP_MS)
2463
+ t.unref?.()
2464
+ pendingNotices.set(key, t)
2465
+ }
2466
+
2467
+ // --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
2468
+ // Un dispositivo con cert `vault:admin` puede ADMITIR y EXPULSAR miembros sin venir
2469
+ // al PC. No puede cambiar permisos, traspasar el mando, conceder `admin` ni tocar los
2470
+ // secretos: esas operaciones no existen como mensaje, a propósito. Así un aparato con
2471
+ // `admin` robado hace daño acotado y reversible (se le revoca) en vez de poder dejar
2472
+ // al dueño fuera de su propia cuenta, que no tiene vuelta atrás.
2473
+ /** Últimas entradas de la bitácora (JSONL), de la más reciente hacia atrás. */
2474
+ function readActivity (limit = 100) {
2475
+ try {
2476
+ // Cada línea va cifrada en reposo: se abre antes de leerla. Sin esto la consola
2477
+ // remota recibía siempre una bitácora vacía.
2478
+ return (disk.read(activityFile) || '').split('\n').filter(Boolean).slice(-limit)
2479
+ .map((l) => { try { return JSON.parse(activityAtRest.decrypt(l)) } catch (_) { return null } }).filter(Boolean).reverse()
2480
+ } catch (_) { return [] }
2481
+ }
2482
+
2483
+ /**
2484
+ * Avisa a TODOS los miembros de que el perfil cambió, firmado por la maestra. Sin
2485
+ * esto, administrar a distancia sería invisible para el resto de los dispositivos —
2486
+ * y esa visibilidad es lo que hace DETECTABLE a un admin comprometido. Va por
2487
+ * `sendByPubkey`, así que al que está apagado le llega cuando encienda (cola 24 h).
2488
+ */
2489
+ async function notifyMembers (ev, info = {}) {
2490
+ try {
2491
+ // SIN FIRMA, y no falta nada: quien lo usa es el ACTA que viaja al lado, que se
2492
+ // autentica sola (otra bóveda la adopta por `canAdopt`; el teléfono solo refresca y
2493
+ // pregunta). Lo firmaba la maestra, que no firma con la bóveda cerrada: el aviso
2494
+ // reventaba antes de salir y el acta nueva no llegaba a nadie.
2495
+ const body = { ev, ...info, ts: Date.now() }
2496
+ // EL ACTA VIAJA CON EL AVISO. Sin esto, un miembro se enteraba de que «algo cambió»
2497
+ // pero no de QUÉ, y no veía el acta nueva hasta renovar su cert — hasta 30 días.
2498
+ // Para un aparato eso era lento; para OTRA BÓVEDA es fatal: se le concede `sella` y
2499
+ // no puede sellar, porque su copia del acta no lo dice todavía.
2500
+ // No es un dato secreto (es pública dentro del perfil y estos son sus miembros), y
2501
+ // quien la recibe la adopta por las reglas de §2.4.1, así que una vieja o ajena no
2502
+ // hace daño: se rechaza sola.
2503
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
2504
+ const { issued } = await identity.listDelegations()
2505
+ // A QUIÉN SE AVISA: a los MIEMBROS DEL ACTA, no solo a quien esta bóveda enroló.
2506
+ //
2507
+ // Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
2508
+ // eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
2509
+ // `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
2510
+ // acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
2511
+ // tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
2512
+ // quedaban con actas distintas y la del dueño sin enterarse de nada.
2513
+ //
2514
+ // El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
2515
+ // suma porque cubre a quien tiene cert y todavía no aparece ahí.
2516
+ const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
2517
+ const yo = identity.me?.publickey || null
2518
+ // UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
2519
+ // MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
2520
+ // y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
2521
+ // cuidado que en `notifyNsChange`.
2522
+ const seen = new Set()
2523
+ const avisar = (pub) => {
2524
+ if (!pub || pub === yo || seen.has(pub)) return
2525
+ seen.add(pub)
2526
+ // El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
2527
+ // bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
2528
+ // de log cuesta días (la última vez, tres).
2529
+ // CALLADO (`quiet`): se encola igual, pero no toca el timbre push. Un aviso de que
2530
+ // el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
2531
+ // sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
2532
+ // El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
2533
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, acta }, { quiet: true, app: 'vault' }) }
2534
+ catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
2535
+ }
2536
+ for (const m of miembros) avisar(m.pub)
2537
+ for (const d of issued || []) avisar(d.sub)
2538
+ log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
2539
+ } catch (e) { log('[vault] could not notify members of the change:', e.message) }
2540
+ // Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
2541
+ // quedarían con el acta de ayer y repartiendo sobres de un aparato ya revocado.
2542
+ await pushToReplicas(ev)
2543
+ }
2544
+
2545
+ /**
2546
+ * EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Un replicador dice por dónde va en
2547
+ * cuanto se conecta, y si va por detrás se le empuja.
2548
+ *
2549
+ * Sin esto, la bóveda solo empujaba al cambiar algo: un replicador que se enciende
2550
+ * después se quedaba vacío hasta el siguiente cambio, que puede no llegar en semanas.
2551
+ * Y el empujón de un enrolamiento se manda a un aparato que todavía no está conectado.
2552
+ * Lo destapó el smoke y era un hueco del diseño, no del código.
2553
+ *
2554
+ * El acuse va FIRMADO por el replicador y se comprueba contra el acta: sin eso, cualquiera
2555
+ * podría pedirle a la bóveda que reparta el acta y los sobres mandando un acuse inventado.
2556
+ */
2557
+ async function handleReplicaAck (from, p) {
2558
+ const body = p?.body
2559
+ if (!body || body.op !== 'replica.ack') return
2560
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
2561
+ if (!record) return
2562
+ // LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
2563
+ // identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
2564
+ // el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
2565
+ const quien = body.publickey
2566
+ if (typeof quien !== 'string' || !Acta.memberCan(record, quien, 'replica')) {
2567
+ return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
2568
+ }
2569
+ if (!(await verifyDeviceSig({ publickey: quien, data: body, signature: p.signature }))) {
2570
+ return log('[vault] a replica ack does not check out against its own key: ignored')
2571
+ }
2572
+ if (typeof body.seq === 'number' && body.seq >= record.seq) return
2573
+ log(`[vault] a replica is at #${body.seq} and we are at #${record.seq}: catching it up`)
2574
+ await pushToReplicas('catch-up')
2575
+ }
2576
+
2577
+ /**
2578
+ * EMPUJAR A LOS REPLICADORES lo que tienen que poder repartir: el acta y los sobres.
2579
+ *
2580
+ * Se manda TODO firmado de antes —el acta la selló la maestra, cada sobre lo firmó la
2581
+ * llave de sellado— así que un replicador no puede falsificar nada de lo que le damos.
2582
+ * Y va cerrado a su destinatario, no a él: reparte sobres que no puede abrir. Es lo que
2583
+ * permite ponerlo en una máquina en la que no confías (`docs/replicas.md` §8.bis).
2584
+ *
2585
+ * Se empuja después de cada cambio, y no se espera acuse: si un replicador está apagado
2586
+ * se queda atrás y lo alcanza el siguiente empujón. Un replicador atrasado no es un
2587
+ * peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
2588
+ * disponibilidad, que es exactamente lo que un replicador arriesga.
2589
+ */
2590
+ async function pushToReplicas (motivo = 'change') {
2591
+ try {
2592
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
2593
+ if (!record) return
2594
+ const replicas = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'replica'))
2595
+ if (!replicas.length) return
2596
+
2597
+ // UN SOBRE POR (CAJÓN, APARATO), porque `bundleFor` lo talla a quien lo pide: lleva
2598
+ // solo las envolturas de ESE aparato. Mandar uno genérico no serviría de nada.
2599
+ const bundles = {}
2600
+ for (const m of record.members || []) {
2601
+ if (!m.cn) continue
2602
+ try { bundles[`${m.cn} ${m.pub}`] = secrets.bundleFor(m.cn, m.pub) } catch (_) {}
2603
+ }
2604
+ const body = { op: 'replica.push', seq: record.seq, acta: record, bundles, ts: Date.now() }
2605
+ const seal = await signSeal(body)
2606
+ if (!seal) return log('[vault] cannot push to the replicas: the record names no sealing key of ours')
2607
+ let n = 0
2608
+ for (const r of replicas) {
2609
+ try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
2610
+ catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
2611
+ }
2612
+ log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${motivo}`)
2613
+ } catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
2614
+ }
2615
+
2616
+ /**
2617
+ * MOSTRADOR DE VARIABLES para la consola remota (`lib/src/admin.js` enruta; la política
2618
+ * y la cripto viven aquí, que es donde están la clave y el disco).
2619
+ *
2620
+ * Dos reglas, y son toda la frontera:
2621
+ *
2622
+ * 1. **El valor de una PRIVADA no sale de esta máquina.** Ni para un aparato tuyo con
2623
+ * `admin`. Lo que viaja de una privada es su nombre y que es privada — con eso se
2624
+ * le puede poner un valor nuevo a ciegas, que es lo que hace falta para rotarla.
2625
+ * 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
2626
+ * transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
2627
+ */
2628
+ /**
2629
+ * La llave de administración a partir de lo que venga en el sobre de la consola
2630
+ * remota. `undefined` si no trae contraseña — entonces el store cae a la de la
2631
+ * máquina, que es lo correcto para un perfil que no tiene ninguna.
2632
+ *
2633
+ * La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
2634
+ */
2635
+ /**
2636
+ * La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
2637
+ * operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
2638
+ */
2639
+ async function adminKeyFrom (payload) {
2640
+ const pwd = payload?.password
2641
+ if (typeof pwd !== 'string' || !pwd) return undefined
2642
+ if (typeof deriveAdminKey !== 'function') return undefined
2643
+ return deriveAdminKey(pwd)
2644
+ }
2645
+
2646
+ const varsDesk = {
2647
+ async list ({ caller = null } = {}) {
2648
+ // EL VAULT DECIDE QUÉ ENVÍA PARA LEER (dueño, 2026-09-02: «la consola debe tener todos
2649
+ // los sobres; la diferencia es si el vault se los envía para la lectura o no»).
2650
+ //
2651
+ // Tener la envoltura y recibir el sobre son dos cosas: quien administra tiene la
2652
+ // envoltura de cada PÚBLICA, y por eso aquí se le manda el sobre de esas y solo de
2653
+ // esas. Las privadas de un cajón con dueño no se mandan — y aunque se mandaran no
2654
+ // podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
2655
+ // propósito: que uno falle no basta para leerlas.
2656
+ //
2657
+ // Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
2658
+ // aprender cómo se llaman tus variables ni qué servicios corres.
2659
+ return {
2660
+ enc: await identity.sealContent(JSON.stringify({
2661
+ ns: withEnvelopes(listSecrets(), 'ns', caller), dev: withEnvelopes(await listDeviceSecrets(), 'dev', caller),
2662
+ // Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
2663
+ // enseñarlo donde se administra, no solo en el log del servicio.
2664
+ incomplete: await incompleteMembers(),
2665
+ // Lo que quedó a deber un sellado: quien administra a distancia tiene que
2666
+ // poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
2667
+ // variable con la contraseña) y la bóveda no.
2668
+ pending: await secretDebts(),
2669
+ // Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
2670
+ // llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
2671
+ // El comentario de aquí decía «la consola lo dice en voz alta» y la consola
2672
+ // no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
2673
+ hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })(),
2674
+ // SI ESTÁ CERRADA, Y SI SE PUEDE ABRIR DESDE AQUÍ. La consola no tenía forma de
2675
+ // saberlo: se enteraba solo al chocar con un `vault-locked` intentando algo. Y
2676
+ // `remoteUnlock` distingue las dos razones por las que el formulario no aparece —
2677
+ // «no está cerrada» y «no hay contraseña de admin puesta»— que son cosas
2678
+ // distintas y llevan a acciones distintas.
2679
+ locked: (() => { try { return !!isLocked() } catch (_) { return true } })(),
2680
+ remoteUnlock: (() => { try { return !!hasSecondary() } catch (_) { return false } })()
2681
+ }))
2682
+ }
2683
+ },
2684
+ /**
2685
+ * PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la lista sale del ACTA
2686
+ * (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
2687
+ * exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
2688
+ */
2689
+ async recipients ({ ns, pub, public: isPublic }) {
2690
+ // LA VISIBILIDAD CAMBIA LA LISTA: una pública se envuelve además para quien
2691
+ // administra, así que hay que decirlo al preguntar. Pedirla sin decirlo y luego
2692
+ // guardarla como pública produciría un sobre que la consola no puede abrir — y la
2693
+ // bóveda lo rechazaría, que es lo correcto pero desorienta.
2694
+ return secrets.recipientsFor(ns ? `ns:${ns}` : `dev:${pub}`, { public: !!isPublic })
2695
+ },
2696
+ /**
2697
+ * GUARDAR UNA VARIABLE **SIN VERLA** (dueño, 2026-09-01).
2698
+ *
2699
+ * «La bóveda cerrada no puede ver el valor; debe confiar en la firma del admin y en el
2700
+ * contenido de esos sobres, y es la razón por la que al abrir la bóveda rehace los
2701
+ * sobres: por si alguno tiene alguna incoherencia.»
2702
+ *
2703
+ * Antes esto abría el sobre para sacar el valor en claro y volvía a cerrarlo. Ese
2704
+ * rodeo era el ÚNICO motivo por el que la llave de cifrado del perfil tenía que estar
2705
+ * accesible con la bóveda cerrada — o sea, la razón por la que una copia del disco
2706
+ * abría todo lo dirigido al perfil.
2707
+ *
2708
+ * Ahora llega hecho: el valor cifrado con una CEK nueva y esa CEK envuelta para cada
2709
+ * destinatario. Aquí se comprueba lo que se puede comprobar SIN la llave: que las
2710
+ * envolturas cubren exactamente a quien dice el acta. Lo que no se puede comprobar
2711
+ * —que dentro haya lo que dice— lo corrige el repaso al abrir la bóveda.
2712
+ *
2713
+ * Una PÚBLICA sigue viajando en claro y por el camino de siempre: marcarla pública es
2714
+ * precisamente decir que no hay nada que ocultar.
2715
+ */
2716
+ async set ({ ns, pub, key, sealed, caller = null, public: isPublic, by: who = null }) {
2717
+ const owner = ns ? `ns:${ns}` : `dev:${pub}`
2718
+ if (sealed) {
2719
+ // PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
2720
+ // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
2721
+ // marca solo decide si se entrega sin aprobación.
2722
+ // Si esto va a pedir aprobación, al aprobarse puede EDITAR; si no, solo añadir.
2723
+ const ask = needsApproval(caller, await refreshActa())
2724
+ const apply = async () => {
2725
+ await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
2726
+ await checkRecipients(owner, sealed.wraps, !!isPublic)
2727
+ await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2728
+ audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
2729
+ await settleDebts(owner)
2730
+ }
2731
+ // Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
2732
+ // minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
2733
+ await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
2734
+ await checkRecipients(owner, sealed.wraps, !!isPublic)
2735
+ const pending = await holdWrite({ caller, ns, pub, keys: [key], apply, ask })
2736
+ if (pending) return { ok: true, key, pending }
2737
+ await apply()
2738
+ return { ok: true, key }
2739
+ }
2740
+ // NO HAY OTRO CAMINO (dueño, 2026-09-02: «no dejes caminos viejos ni fallbacks; no hay
2741
+ // que hacer retrocompatibilidad y es una regla del estado actual del proyecto, por eso
2742
+ // quedan luego backdoors»).
2743
+ //
2744
+ // Aquí estaba el `enc`: la consola sellaba el valor AL PERFIL y la bóveda lo ABRÍA
2745
+ // para volver a cerrarlo. Ese descifrado era el único motivo por el que la llave de
2746
+ // cifrado del perfil tenía que estar accesible con la bóveda cerrada — o sea, la
2747
+ // razón por la que una copia del disco abría todo lo dirigido al perfil. Dejarlo
2748
+ // «por si acaso» habría dejado el agujero entero abierto con otro nombre.
2749
+ throw Object.assign(
2750
+ new Error('var.set: the value must come already sealed (`sealed`); the old `enc` path is gone — build it with buildSealedVar()'),
2751
+ { code: 'needs-sealed' })
2752
+ },
2753
+ /**
2754
+ * VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
2755
+ * al servicio de que su configuración cambió, y el servicio SALE para releerla entera
2756
+ * (`watchEnv`). Guardadas de una en una, quien administra a distancia reiniciaba el
2757
+ * servicio una vez por variable, y las primeras veces arrancaba con la configuración a
2758
+ * medio poner. Juntas: un guardado, un aviso, un reinicio.
2759
+ *
2760
+ * Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
2761
+ * transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
2762
+ */
2763
+ async setMany ({ ns, pub, items, caller = null, by: who = null, fromService = false }) {
2764
+ // CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
2765
+ // comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
2766
+ // `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
2767
+ // a tener la llave de cifrado accesible con la bóveda cerrada.
2768
+ if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
2769
+ const owner = ns ? `ns:${ns}` : `dev:${pub}`
2770
+ const keys = []
2771
+ // ¿SE PIDE APROBACIÓN? Desde la mesa del admin, lo de siempre (`needsApproval`: quien
2772
+ // tiene `unattended` escribe solo). Desde el propio SERVICIO que guarda en su cajón, la
2773
+ // aprobación es SOLO PARA EDITAR (dueño, 2026-10-12: «la aprobación solamente es para
2774
+ // editar una env; para almacenarla es directo»): una variable nueva no le quita nada a
2775
+ // nadie y se guarda en el acto; reemplazar una que ya existe pide el sí de quien
2776
+ // aprueba, tenga o no `unattended` el servicio (dueño, 2026-09-29: «el unattended
2777
+ // también pediría aprobación»). Una tanda con alguna que ya existe se pide ENTERA: va
2778
+ // todo o nada. Sin nadie que apruebe no se pide —la regla de siempre— y lo que ya
2779
+ // existe falla con `exists`.
2780
+ const record = await refreshActa()
2781
+ const ask = fromService
2782
+ ? hasApprover(record) && items.some((it) => it?.key && secrets.has(owner, it.key))
2783
+ : needsApproval(caller, record)
2784
+ for (const it of items) {
2785
+ if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
2786
+ await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
2787
+ await checkRecipients(owner, it.sealed.wraps, !!it.public)
2788
+ }
2789
+ // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
2790
+ // configuración que nadie quiso, y el servicio se reinicia con ella.
2791
+ const apply = async () => {
2792
+ for (const it of items) {
2793
+ await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
2794
+ await checkRecipients(owner, it.sealed.wraps, !!it.public)
2795
+ }
2796
+ for (const it of items) {
2797
+ await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
2798
+ keys.push(it.key)
2799
+ }
2800
+ audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2801
+ await settleDebts(owner)
2802
+ }
2803
+ const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply, ask })
2804
+ if (pending) return { ok: true, keys: [], pending }
2805
+ await apply()
2806
+ return { ok: true, keys }
2807
+ }
2808
+ }
2809
+
2810
+ /**
2811
+ * GUARDAR VARIABLES TAMBIÉN PIDE PERMISO (dueño, 2026-09-23: «si el admin no es
2812
+ * unattended, sí debería pedir permiso para almacenar variables»).
2813
+ *
2814
+ * Es el mismo permiso que ya decide si un aparato se lleva las claves solo: quien tiene
2815
+ * `unattended` escribe sin preguntar; quien no, deja la escritura en espera y el teléfono
2816
+ * decide. Un permiso, una regla, para leer y para escribir.
2817
+ *
2818
+ * No se espera DENTRO de la llamada: la consola corta a los 20 s y aprobar puede tardar
2819
+ * cinco minutos. Se contesta «pendiente» en el acto, el sobre se queda en memoria (va
2820
+ * sellado, la bóveda no lo abre) y se escribe al aprobar. Denegado o vencido, se tira.
2821
+ *
2822
+ * Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
2823
+ */
2824
+ async function holdWrite ({ caller, ns, pub, keys, apply, ask }) {
2825
+ if (!(ask ?? needsApproval(caller, await refreshActa()))) return null
2826
+ // Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
2827
+ // acta pudo cambiar en medio.
2828
+ const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
2829
+ const pend = await awaitApproval({
2830
+ device: caller, ns: target, kind: 'write', what: 'vars',
2831
+ // LOS NOMBRES van en el contexto, que viaja SELLADO al que aprueba: el proxio no
2832
+ // tiene por qué aprender cómo se llama la configuración de un servicio.
2833
+ ctx: { keys },
2834
+ onAnswer: async (ok) => {
2835
+ if (!ok) return
2836
+ await apply()
2837
+ await notifyMembers('vars', { by: await deviceIdOf(caller).catch(() => null), keys, ns: ns || null })
2838
+ }
2839
+ })
2840
+ return pend.id
2841
+ }
2842
+
2843
+ /**
2844
+ * BLOQUEAR O DESBLOQUEAR A MANO, desde la bóveda (CLI/TUI) o desde la consola de
2845
+ * administración. El camino normal para bloquear es el aprobador (`handleApproval`,
2846
+ * `block`); desbloquear solo pasa por aquí, que es lo que pidió el dueño.
2847
+ */
2848
+ async function setBlocked (pub, on, { by = 'vault' } = {}) {
2849
+ const deviceId = await deviceIdOf(pub).catch(() => null)
2850
+ if (on) {
2851
+ const record = await refreshActa()
2852
+ const label = (record?.members || []).find((m) => m.pub === pub)?.label || ''
2853
+ const b = blocked.block({ pub, deviceId, label, by, reason: 'manual' })
2854
+ grants.revokeAll({ device: pub })
2855
+ audit('blocked', { device: deviceId, by, kind: 'manual' })
2856
+ log(`[vault] BLOCKED ${deviceId} by ${by}`)
2857
+ await notifyMembers('blocked', { deviceId, by })
2858
+ return b
2859
+ }
2860
+ const was = blocked.unblock(pub)
2861
+ if (was) {
2862
+ audit('unblocked', { device: deviceId, by })
2863
+ log(`[vault] unblocked ${deviceId} by ${by}`)
2864
+ await notifyMembers('unblocked', { deviceId, by })
2865
+ }
2866
+ return was
2867
+ }
2868
+
2869
+ const admin = createAdminDesk({
2870
+ desk,
2871
+ deviceIdOf,
2872
+ audit,
2873
+ blocked: { list: () => blocked.list(), set: setBlocked },
2874
+ // Abrir a distancia. Va inyectado porque `admin.js` es puro y esto necesita cripto y
2875
+ // disco; `null` si este perfil no lo soporta, y entonces la operación contesta que no.
2876
+ unlockDesk,
2877
+ // El MISMO candado que frena firmar y editar el perfil: revocar reescribe el acta y
2878
+ // configurar toca los secretos, y ninguna de las dos se hace con la bóveda cerrada.
2879
+ isLocked,
2880
+ notify: notifyMembers,
2881
+ readActivity,
2882
+ vars: varsDesk,
2883
+ // CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
2884
+ // qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
2885
+ // hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
2886
+ // fiar exigía revocarlo entero. Con el cruce, deja de administrar en el acto.
2887
+ verify: async ({ data, signature, cert }) => {
2888
+ const chk = await verifyChain({
2889
+ data, signature, cert,
2890
+ expectedScope: SCOPE.ADMIN, ...(await contextoActa()), revoked: await revocationSet()
2891
+ })
2892
+ // Quitarse a UNO MISMO desde la consola remota entra por aquí: la segunda petición
2893
+ // que mande el aparato ya llega con el certificado retirado. Que se entere con el
2894
+ // aviso firmado, en vez de con un error suelto que no puede borrar nada.
2895
+ if (!chk.ok) {
2896
+ await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
2897
+ return chk
2898
+ }
2899
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
2900
+ if (!record || !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: record ? 'acta' : 'sin-acta' }
2901
+ return chk
2902
+ }
2903
+ })
2904
+
2905
+ /**
2906
+ * RENUNCIA: un miembro se quita capacidades a sí mismo y la bóveda la SELLA en el acta.
2907
+ *
2908
+ * NO se pide certificado, y es a propósito: el registro va firmado por el propio miembro
2909
+ * y solo puede QUITAR, así que honrarlo no puede hacer daño — y exigir un cert válido
2910
+ * dejaría fuera justo el caso que la justifica (un aparato que ya no es de fiar, o cuyo
2911
+ * cert caducó). Sin esto la renuncia se quedaba en el dispositivo: la bóveda seguía
2912
+ * teniendo escrito que podía firmar y le seguía aceptando peticiones.
2913
+ */
2914
+ async function handleRenounce (from, p) {
2915
+ const record = p?.data?.record || p?.record
2916
+ if (!record || typeof record !== 'object') return reply(from, { type: MSG.ERROR, error: 'renounce: record required' })
2917
+ if (!(await Acta.verifyRenounce(record).catch(() => false))) {
2918
+ audit('rejected', { what: 'renounce', reason: 'signature' })
2919
+ return reply(from, { type: MSG.ERROR, error: 'renounce: the signature is not the member own' })
2920
+ }
2921
+ // UNA RENUNCIA QUE NO QUITA NADA NO SE HONRA, y esto es lo que corta el replay barato.
2922
+ //
2923
+ // El registro va firmado y lleva `ts`, pero nadie lo miraba: cualquiera que lo viera
2924
+ // pasar por el proxio podía volver a mandarlo cuando quisiera. Como `absorbRenounce`
2925
+ // SELLA UN ACTA NUEVA cada vez, repetirlo hacía subir el `seq` y empujar a todos los
2926
+ // aparatos sin que hubiera cambiado nada — y dejaba en la bitácora una renuncia con el
2927
+ // nombre del aparato, que es lo peor: la bitácora es la prueba, y ahí decía algo que
2928
+ // ese aparato no hizo.
2929
+ //
2930
+ // Mirando lo que el acta dice HOY, un registro repetido no quita nada y se para aquí.
2931
+ // Lo que esto NO cierra, y queda anotado: si el dueño vuelve a conceder ese permiso, el
2932
+ // mismo registro viejo lo quita otra vez. Cerrarlo del todo pide que el acta recuerde
2933
+ // la fecha de la última renuncia por miembro y rechace las que no sean posteriores.
2934
+ const actual = (await identity.profileActa?.().catch(() => null))?.acta || null
2935
+ if (!actual) {
2936
+ audit('rejected', { what: 'renounce', reason: 'sin-acta' })
2937
+ return reply(from, { type: MSG.ERROR, error: 'renounce: this vault has no record to apply it to' })
2938
+ }
2939
+ const tiene = new Set(Acta.effectiveCaps(actual, record.member) || [])
2940
+ if (!(record.caps || []).some((c) => tiene.has(c))) {
2941
+ audit('rejected', { what: 'renounce', reason: 'nada-que-quitar' })
2942
+ return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
2943
+ }
2944
+ try {
2945
+ // A LA SUBACTA PRIMERO, y aquí está el arreglo: desde este momento surte efecto,
2946
+ // esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
2947
+ // de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
2948
+ // único momento en que de verdad hace falta.
2949
+ const guardada = await subacta.add(record)
2950
+ if (!guardada.ok && guardada.reason === 'no-aceptable') {
2951
+ audit('rejected', { what: 'renounce', reason: 'no-aceptable' })
2952
+ return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
2953
+ }
2954
+ const device = await deviceIdOf(record.member).catch(() => null)
2955
+ audit('renounce', { device, caps: record.caps })
2956
+ log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')} — in effect now`)
2957
+
2958
+ // Y SE SELLA SI SE PUEDE. Con la bóveda abierta entra en el acta y lo respeta toda la
2959
+ // cuenta; cerrada se queda pendiente y lo absorbe el `unlock`. Las dos ramas son
2960
+ // correctas, así que el fallo al sellar NO es un error para quien renunció.
2961
+ let seq = null
2962
+ try {
2963
+ const r = await identity.absorbRenounce(record)
2964
+ subacta.clear([record])
2965
+ seq = r?.seq ?? null
2966
+ } catch (e) {
2967
+ log(`[vault] the renounce stays pending until the vault is opened (${e.message})`)
2968
+ }
2969
+ await notifyMembers('renounce', { deviceId: device, caps: record.caps })
2970
+ reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq, pending: seq === null })
2971
+ } catch (e) {
2972
+ reply(from, { type: MSG.ERROR, error: 'renounce: ' + e.message })
2973
+ }
2974
+ }
2975
+
2976
+ async function handleAdmin (from, p) {
2977
+ // La frescura se comprueba aquí (es del transporte, igual que en el resto de
2978
+ // handlers); el resto de la regla vive en el módulo puro.
2979
+ if (!isFresh(p.data)) return staleReply(from)
2980
+ const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
2981
+ // El `code` viaja aparte del texto: el texto está en inglés y puede cambiar, el código
2982
+ // es lo que empareja quien llama (`vault-locked` ≠ «no autorizado»).
2983
+ // EL FRENO VIAJA CON EL RECHAZO. `tries` y `waitSec` solo los trae abrir a distancia, y
2984
+ // sin ellos el admin reintenta contra una puerta que ya no responde y no sabe por qué —
2985
+ // el mismo silencio que se arregló en el resto del ecosistema.
2986
+ if (!r.ok) {
2987
+ return reply(from, {
2988
+ type: MSG.ERROR,
2989
+ error: r.error,
2990
+ ...(r.code ? { code: r.code } : {}),
2991
+ ...(r.tries != null ? { tries: r.tries } : {}),
2992
+ ...(r.waitSec != null ? { waitSec: r.waitSec } : {})
2993
+ })
2994
+ }
2995
+ reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
2996
+ }
2997
+
2998
+ // API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
2999
+ // sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
3000
+ /**
3001
+ * La llave con la que se abre la copia maestra.
3002
+ *
3003
+ * Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
3004
+ * contraseña se cae a la llave de la máquina**, que es exactamente la protección
3005
+ * que había antes de todo esto: el disco sigue cifrado, pero su material vive en
3006
+ * ese mismo disco, así que una copia del disco lo abre.
3007
+ *
3008
+ * Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
3009
+ * pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
3010
+ * diseño). Prometer una protección que no está puesta es peor que no tenerla.
3011
+ */
3012
+ const adminKeyOr = (adminKey) => adminKey || disk.machineKey()
3013
+
3014
+ // `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
3015
+ // usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
3016
+ // listar y borrar no la piden (ver `secretsStore.js`).
3017
+ /**
3018
+ * Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
3019
+ * (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
3020
+ * CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
3021
+ * que este diseño quita.
3022
+ */
3023
+ async function nsMembers (ns) {
3024
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3025
+ if (!record) return []
3026
+ // LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
3027
+ // lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
3028
+ //
3029
+ // Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
3030
+ // DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
3031
+ // así que un cajón llamado `vault` —nombre perfectamente válido— la contaba entre sus
3032
+ // dueños. No llegaba a recibir sobre (no tiene `encPub`, y `recipientsFor` los filtra),
3033
+ // pero la salvaguarda era un accidente: bastaba que alguien le apuntara una `encPub`
3034
+ // para envolverle la llave a la llave que firma con la bóveda CERRADA. Se comprueba el
3035
+ // permiso, que es lo que de verdad se quería preguntar.
3036
+ return (record.members || []).filter((m) => m.cn === ns && Acta.memberCanReadSecrets(record, m.pub, ns))
3037
+ }
3038
+
3039
+ /**
3040
+ * Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
3041
+ * cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
3042
+ * ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
3043
+ * frase que se escribe en cualquier parte a una llave que no sale del aparato.
3044
+ *
3045
+ * La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
3046
+ * ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
3047
+ * diseño quita. Hay un test que lo afirma.
3048
+ */
3049
+ async function adminDevices () {
3050
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3051
+ return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
3052
+ }
3053
+
3054
+ /**
3055
+ * Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
3056
+ * importa (decidida por el dueño el 2026-08-22):
3057
+ *
3058
+ * **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
3059
+ * hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
3060
+ * token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
3061
+ * alguien se lleve tu portátil con la sesión puesta.
3062
+ *
3063
+ * Dos cosas que hacen que esto se sostenga:
3064
+ *
3065
+ * · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
3066
+ * que la bóveda se negara a entregarlo, pero entonces la protección sería una
3067
+ * política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
3068
+ * llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
3069
+ * se crea no se puede saltar.
3070
+ * · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
3071
+ * dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
3072
+ * se puede forzar esperando a que ese servicio esté caído.
3073
+ *
3074
+ * Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
3075
+ * miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
3076
+ * abre la envoltura de recuperación, que `wrapAll` añade siempre.
3077
+ */
3078
+ /**
3079
+ * Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
3080
+ * el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
3081
+ * quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
3082
+ * qué pidió; aquí solo se valida el remitente.
3083
+ */
3084
+ async function handleRewrapOk (payload) {
3085
+ const d = payload?.data
3086
+ if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
3087
+ const signer = payload?.cert?.sub
3088
+ if (!signer) return
3089
+ if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
3090
+ return log('[vault] a handed key arrived BADLY SIGNED: ignored')
3091
+ }
3092
+ // Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
3093
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3094
+ const m = (record?.members || []).find((x) => x.pub === signer)
3095
+ const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
3096
+ if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
3097
+ for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
3098
+ }
3099
+
3100
+ /** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
3101
+ const rewrapWaiters = new Set()
3102
+ const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
3103
+
3104
+ /**
3105
+ * LAS OTRAS BÓVEDAS —los miembros con el permiso `sella`— entran en TODOS los cajones,
3106
+ * también en los que tienen dueño. Decidido por el dueño el 2026-08-30.
3107
+ *
3108
+ * Y no contradice la regla de agosto —«un cajón con dueño no se envuelve para quien
3109
+ * administra»—, porque un cosellador **no es quien administra**: es un master. Aquella
3110
+ * regla saca a los aparatos de consola (un navegador, un portátil con la sesión puesta),
3111
+ * cuyo compromiso es probable y cuya necesidad es comodidad. Una segunda bóveda existe
3112
+ * justamente para poder REGENERAR los sobres el día que la primera no esté, y regenerar
3113
+ * exige abrir: dejarla fuera la haría inútil para el único desastre que viene a cubrir.
3114
+ *
3115
+ * El precio, que se dice y no se esconde: a partir de aquí son DOS los discos cuya
3116
+ * captura abre ese cajón. Eso es exactamente lo que cuesta sobrevivir a perder uno.
3117
+ */
3118
+ async function cosealerMembers () {
3119
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3120
+ if (!record) return []
3121
+ // Sin `encPub` no hay a dónde envolver: se queda en deuda y se ve como `sinLlave`,
3122
+ // igual que cualquier otro miembro incompleto.
3123
+ return (record.members || []).filter((m) =>
3124
+ m.pub !== master && m.encPub && Acta.memberCan(record, m.pub, 'sealer'))
3125
+ }
3126
+
3127
+ /** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
3128
+ /** Sin repetidos por llave: un aparato puede entrar por dos vías (dueño y admin). */
3129
+ const dedup = (miembros) => {
3130
+ const vistos = new Set()
3131
+ return miembros.filter((m) => m && !vistos.has(m.pub) && vistos.add(m.pub))
3132
+ }
3133
+ const conCoselladores = (base, co) => {
3134
+ const vistos = new Set(base.map((m) => m.pub))
3135
+ return [...base, ...co.filter((m) => !vistos.has(m.pub))]
3136
+ }
3137
+
3138
+ async function recipientsOf (owner, { public: isPublic = false } = {}) {
3139
+ const co = await cosealerMembers()
3140
+ // UNA PÚBLICA SE ENVUELVE TAMBIÉN PARA QUIEN ADMINISTRA (dueño, 2026-09-02: «la consola
3141
+ // debe poder leer todas las variables… debe tener todos los sobres; la diferencia es si
3142
+ // el vault se los envía para la lectura o no» → «cambia la regla y di que se envuelven
3143
+ // las públicas»).
3144
+ //
3145
+ // Puede decidirse variable a variable porque cada escritura estrena su propia CEK: el
3146
+ // llavero guarda una generación por valor, no una por cajón.
3147
+ //
3148
+ // Y la PRIVADA de un cajón con dueño sigue sin envoltura para la consola, que es la
3149
+ // regla de agosto y su motivo no ha cambiado: el token de R2 no tiene por qué poder
3150
+ // abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
3151
+ // para quien administra — puede leerla— además de que se despacha sin aprobación.
3152
+ const admins = isPublic ? await adminDevices() : []
3153
+ // EL PERFIL SE LE ENVUELVE A QUIEN PUEDE HABLAR POR TI (`docs/datos-del-perfil.md`).
3154
+ //
3155
+ // Va explícito y antes que nada porque si no cae en la rama de «cajón sin dueño» y
3156
+ // acabaría envuelto solo para quien ADMINISTRA — que no es lo que se decidió: leer y
3157
+ // escribir tu identidad es `firma`, y no todos los que firman administran. Un teléfono
3158
+ // normal firma y no administra, y tiene que ver tu perfil.
3159
+ if (owner === PROFILE_OWNER) {
3160
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
3161
+ const firman = (record?.members || [])
3162
+ .filter((m) => m.encPub && Acta.memberCan(record, m.pub, 'sign'))
3163
+ .map((m) => ({ pub: m.pub, encPub: m.encPub }))
3164
+ return conCoselladores(dedup(firman), co)
3165
+ }
3166
+ if (owner.startsWith('ns:')) {
3167
+ const owned = await nsMembers(owner.slice(3))
3168
+ // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
3169
+ // SIEMPRE: si no, no quedaría nadie que pudiera abrirlo sin la frase.
3170
+ const base = owned.length ? [...owned, ...admins] : await adminDevices()
3171
+ return conCoselladores(dedup(base), co)
3172
+ }
3173
+ const pub = owner.slice(owner.indexOf(':') + 1)
3174
+ const m = await memberOf(pub)
3175
+ // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
3176
+ if (m?.cn) return conCoselladores(dedup([m, ...admins]), co)
3177
+ return conCoselladores(dedup([...(m ? [m] : []), ...await adminDevices()]), co)
3178
+ }
3179
+
3180
+ /**
3181
+ * Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
3182
+ * del acta junto a la firma: es lo que le dice a quien verifica con qué llave
3183
+ * comprobarla, porque esa llave rota con el acta (§8.9).
3184
+ *
3185
+ * Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
3186
+ * es más importante que poder demostrar después de dónde salió.
3187
+ */
3188
+ async function signSeal (body) {
3189
+ try {
3190
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
3191
+ if (!acta?.sealPub) return null
3192
+ const sig = await sealKeys.sign(acta.sealPub, body)
3193
+ return sig ? { seq: acta.seq, sig } : null
3194
+ } catch (_) { return null }
3195
+ }
3196
+
3197
+ /**
3198
+ * Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
3199
+ * del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
3200
+ * abrir y se queda reintentando para siempre, sin decir por qué.
3201
+ *
3202
+ * No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
3203
+ * reiniciaría a todos los nodos del ns para nada.
3204
+ */
3205
+ /**
3206
+ * Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
3207
+ * el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
3208
+ * consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
3209
+ *
3210
+ * Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
3211
+ * que ya no está podría abrir lo que se escriba mañana.
3212
+ */
3213
+ async function markRotationDue (sub) {
3214
+ const m = await memberOf(sub)
3215
+ const ns = m?.cn
3216
+ if (!ns) return
3217
+ try {
3218
+ const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
3219
+ if (r?.rotated != null) {
3220
+ log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
3221
+ audit('secret.rotate', { ns, keys: r.rotated })
3222
+ scheduleNotice(ns)
3223
+ return
3224
+ }
3225
+ } catch (e) {
3226
+ store.setSetting(`rotate-due:${ns}`, String(Date.now()))
3227
+ log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
3228
+ audit('secret.rotate-due', { ns, reason: e.message })
3229
+ }
3230
+ }
3231
+
3232
+ /**
3233
+ * Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
3234
+ * deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
3235
+ * salen juntas:
3236
+ *
3237
+ * - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
3238
+ * - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
3239
+ *
3240
+ * Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
3241
+ * enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
3242
+ * que además nadie ve.
3243
+ */
3244
+ /**
3245
+ * Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
3246
+ * las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
3247
+ * llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
3248
+ * hay quien la teclee.
3249
+ *
3250
+ * Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
3251
+ * vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
3252
+ * otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
3253
+ * mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
3254
+ * (`dotrino-vault secret settle`, que sí pide la frase).
3255
+ *
3256
+ * @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
3257
+ */
3258
+ async function incompleteMembers () {
3259
+ if (secrets.isLegacy?.()) return []
3260
+ // A QUIÉN LE FALTA ALGO LO DICE `recipientsOf`, Y SOLO ÉL — la misma lista con la que
3261
+ // se envuelve al escribir y se rehace el llavero al abrir. Aquí había otra: «un aparato
3262
+ // sin `cn` puede destapar cualquier cajón sin dueño». Para `@me` no es cierto —el
3263
+ // perfil se envuelve a quien puede FIRMAR por ti—, así que un aparato sin `firma` salía
3264
+ // con todos los datos del perfil «sin llave todavía», y abrir la bóveda, que es lo que
3265
+ // el aviso pedía, no podía apagarlo: el llavero ya estaba como decía el acta. Dos
3266
+ // listas para la misma pregunta acaban siempre así.
3267
+ const out = new Map()
3268
+ for (const owner of secrets.owners()) {
3269
+ // Por VISIBILIDAD: a quien administra se le envuelve la pública de un cajón con dueño
3270
+ // y no la privada. La lista de las públicas contiene siempre a la de las privadas.
3271
+ const privados = new Set((await recipientsOf(owner)).map((m) => m.pub))
3272
+ for (const m of await recipientsOf(owner, { public: true })) {
3273
+ if (!m.encPub || m.pub === master) continue
3274
+ const missing = secrets.missingFor(owner, m.pub, { only: (esPublica) => esPublica || privados.has(m.pub) })
3275
+ if (!missing.length) continue
3276
+ const row = out.get(m.pub) || { pub: m.pub, cn: m.cn || null, owners: {} }
3277
+ row.owners[owner] = missing
3278
+ out.set(m.pub, row)
3279
+ }
3280
+ }
3281
+ return [...out.values()]
3282
+ }
3283
+
3284
+ /**
3285
+ * Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
3286
+ * no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
3287
+ * estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
3288
+ */
3289
+ function rotationsDue () {
3290
+ const out = {}
3291
+ for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
3292
+ // La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
3293
+ // nombre del namespace: una sola forma para quien lo lee.
3294
+ if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
3295
+ }
3296
+ return out
3297
+ }
3298
+
3299
+ /**
3300
+ * TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
3301
+ * envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
3302
+ * se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
3303
+ * se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
3304
+ * entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
3305
+ * @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
3306
+ */
3307
+ async function secretDebts () {
3308
+ const out = rotationsDue()
3309
+ for (const m of await incompleteMembers()) {
3310
+ for (const [owner, keys] of Object.entries(m.owners)) {
3311
+ if (out[owner]?.kind === 'rotate') continue
3312
+ out[owner] = out[owner] || { kind: 'rewrap', members: [] }
3313
+ out[owner].members.push({ pub: m.pub, keys })
3314
+ }
3315
+ }
3316
+ return out
3317
+ }
3318
+
3319
+ async function spreadKey (owner, members, adminKey) {
3320
+ if (secrets.isLegacy()) return null
3321
+ // Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
3322
+ // se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
3323
+ // sustituye al reparto en vez de sumarse.
3324
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
3325
+ if (ns && store.getSetting(`rotate-due:${ns}`)) {
3326
+ const rot = await secrets.rotate(owner, members, adminKey)
3327
+ store.setSetting(`rotate-due:${ns}`, undefined)
3328
+ log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
3329
+ audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
3330
+ return rot
3331
+ }
3332
+ try {
3333
+ const r = await secrets.rewrap(owner, members, adminKey)
3334
+ if (r?.sinLlave?.length) {
3335
+ log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
3336
+ audit('secret.nokey', { owner, count: r.sinLlave.length })
3337
+ }
3338
+ return r
3339
+ } catch (e) {
3340
+ // SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
3341
+ // donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
3342
+ // cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
3343
+ // del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
3344
+ // — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
3345
+ // anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
3346
+ // salde un hermano o el abrir la bóveda.
3347
+ 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`)
3348
+ audit('secret.rewrap-due', { owner, reason: e.message })
3349
+ throw e
3350
+ }
3351
+ }
3352
+
3353
+ /**
3354
+ * Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
3355
+ * a leer. `by` es el aparato que lo escribió, y va al histórico.
3356
+ */
3357
+ async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
3358
+ await secrets.set(ns, key, value, isPublic, { by })
3359
+ await settleDebts(`ns:${ns}`)
3360
+ audit('secret.set', { ns, key }); scheduleNotice(ns)
3361
+ }
3362
+
3363
+ /**
3364
+ * SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
3365
+ * desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
3366
+ * verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
3367
+ * único que la frase guarda (§8.3).
3368
+ *
3369
+ * No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
3370
+ * tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
3371
+ */
3372
+ /**
3373
+ * PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
3374
+ *
3375
+ * Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
3376
+ * llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
3377
+ * tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
3378
+ *
3379
+ * La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
3380
+ * podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
3381
+ * destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
3382
+ * solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
3383
+ *
3384
+ * @returns {Promise<{ done: number, asked: number }>}
3385
+ */
3386
+ /**
3387
+ * PEDIRLE A UN HERMANO QUE ENVUELVA — Y QUE SE LO ENTREGUE A LA BÓVEDA (§8.11).
3388
+ *
3389
+ * Regla del dueño (2026-09-01): «un servicio reparte la llave AL VAULT para que este la
3390
+ * reparta después, pero no se la entrega directo; si lo hace sin pasar por el vault está
3391
+ * mal». Es lo que hace este camino, y conviene verlo en el orden de las líneas:
3392
+ *
3393
+ * 1. la BÓVEDA le pide el sobre al hermano (`sendByPubkey` → `REWRAP`);
3394
+ * 2. el hermano le contesta A ELLA, nunca al recién llegado;
3395
+ * 3. la BÓVEDA lo guarda (`putWrap`) y a partir de ahí lo sirve ella.
3396
+ *
3397
+ * El destinatario no habla con nadie, y la bóveda nunca ve la llave en claro: el sobre
3398
+ * llega ya cerrado a la pública del destinatario. Por eso esto funciona con el perfil
3399
+ * cerrado, que es justo cuando hace falta.
3400
+ */
3401
+ async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
3402
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
3403
+ if (!ns) return { done: 0, asked: 0 }
3404
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3405
+ if (!record) return { done: 0, asked: 0 }
3406
+ // Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
3407
+ const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
3408
+ const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
3409
+ if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
3410
+
3411
+ let done = 0
3412
+ for (const gen of pending) {
3413
+ // La llave de sellado firma (el servicio lo comprueba con `verifyResponder`): esto
3414
+ // tiene que funcionar con la bóveda cerrada, y la maestra entonces no firma.
3415
+ const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
3416
+ const seal = await sealOrFail(body)
3417
+ const answer = new Promise((resolve) => {
3418
+ const off = onRewrapOk((d) => {
3419
+ if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
3420
+ off(); resolve(d.wrap)
3421
+ })
3422
+ setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
3423
+ })
3424
+ try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, seal }) } catch (e) {
3425
+ log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
3426
+ continue
3427
+ }
3428
+ const wrap = await answer
3429
+ if (!wrap) continue
3430
+ try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
3431
+ log(`[vault] ${owner}: the handed key was refused (${e.message})`)
3432
+ }
3433
+ }
3434
+ if (done) {
3435
+ audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
3436
+ log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
3437
+ }
3438
+ return { done, asked: pending.length }
3439
+ }
3440
+
3441
+ /**
3442
+ * REHACER EL LLAVERO ENTERO. Se llama al ABRIR la bóveda, que es el único momento en que
3443
+ * se puede: envolver exige la llave del cajón, y esa solo se abre con la frase.
3444
+ *
3445
+ * Lo que falle aquí SALE en `failed`, y quien llame tiene que decirlo. Callarlo fue el
3446
+ * fallo: un cajón que no se pudo reenvolver deja a sus aparatos sin poder leer nada, y
3447
+ * desde fuera la bóveda parecía abierta y sana — «desbloqueado» a secas—. Se descubría
3448
+ * días después, por un servicio que pedía sus claves una y otra vez.
3449
+ */
3450
+ /**
3451
+ * QUE LOS SOBRES EXISTAN PARA TODOS, SIEMPRE — TAMBIÉN AL SELLAR EL ACTA.
3452
+ *
3453
+ * Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
3454
+ * que todos los sobres para todos los dispositivos existan, antes y después de sellar un
3455
+ * acta; es la tarea de la llave maestra, no tiene otra».
3456
+ *
3457
+ * Y es literalmente su otro trabajo: la maestra firma el acta y regenera los sobres. Todo
3458
+ * cambio del acta cambia QUIÉN debe tener envoltura de qué —entra un aparato, se le da
3459
+ * `secrets`, se le quita, aparece un sellador nuevo—, así que sellar sin repasar los
3460
+ * sobres deja el llavero diciendo una cosa y el acta otra. Eso fue justo el agujero: un
3461
+ * servicio en el acta, con su permiso, y sin poder abrir nada.
3462
+ *
3463
+ * Con la bóveda ABIERTA se rehace de verdad. Cerrada no se puede —envolver exige la llave
3464
+ * del cajón— y entonces queda como DEUDA anotada, que es lo que salda el `unlock`. Lo que
3465
+ * no se hace es callarlo.
3466
+ */
3467
+ /**
3468
+ * QUE LOS SOBRES EXISTAN PARA TODOS, CADA VEZ QUE EL ACTA CAMBIA.
3469
+ *
3470
+ * Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
3471
+ * que todos los sobres para todos los dispositivos existan, antes y después de sellar un
3472
+ * acta; es la tarea de la llave maestra, no tiene otra». Y es literalmente su otro
3473
+ * trabajo: firmar el acta y regenerar los sobres.
3474
+ *
3475
+ * SE REHACE ENTERO, no se salda lo que falta. Saldar añade lo que no está pero no MIRA lo
3476
+ * que sobra: la envoltura de alguien a quien ya se le quitó el permiso se quedaría ahí
3477
+ * para siempre y nadie se enteraría. `resealAll` con `exact` deja cada cajón envuelto para
3478
+ * exactamente quien dice el acta —crea lo que falta y RETIRA lo caduco—, que es la única
3479
+ * forma de poder afirmar que el llavero está al día. No toca la red: es reenvolver con
3480
+ * las públicas del acta, así que esperar aquí no cuelga de ningún aparato apagado.
3481
+ *
3482
+ * NO HAY CAMINO PARA «LA BÓVEDA ESTÁ CERRADA» (dueño, 2026-09-01: «es un absurdo, con
3483
+ * bóveda cerrada no puede cambiar el acta»). Sellar el acta necesita la maestra, y cerrada
3484
+ * la maestra no está en memoria — o sea que si se llegó hasta aquí, estaba abierta. Un
3485
+ * `if` para ese caso no sería prudencia: sería un repliegue que le pone cara de trámite
3486
+ * normal a una contradicción. Si aun así falta la llave, se dice como lo que es.
3487
+ */
3488
+ /**
3489
+ * ¿QUIÉN HIZO ESTE SOBRE? Tiene que decirlo, y tiene que ser un MIEMBRO DEL ACTA.
3490
+ *
3491
+ * Regla del dueño (2026-09-01): «los sobres deben traer información de quién los hizo,
3492
+ * deberían tener la firma para que no se cuelen sobres firmados por cualquiera; solo
3493
+ * puede haber sobres firmados por miembros del acta».
3494
+ *
3495
+ * La petición ya viene firmada y comprobada (`verifyChain` + el acta), así que un extraño
3496
+ * no llega hasta aquí. Esto es otra cosa y hace falta igual: la bóveda guarda unos bytes
3497
+ * que NO PUEDE LEER, y sin esta firma lo único que ataba esos bytes a un autor era el
3498
+ * momento de la llamada. Con ella, el registro guardado se explica solo — se puede
3499
+ * comprobar después, en frío, sin fiarse de ningún log.
3500
+ *
3501
+ * El autor tiene que ser QUIEN LLAMA. Aceptar un sobre firmado por otro sería admitir un
3502
+ * relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
3503
+ * las dos comprobaciones lo note.
3504
+ */
3505
+ async function checkAuthor (owner, key, sealed, caller, { allowOverwrite = false } = {}) {
3506
+ const a = sealed?.author
3507
+ if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
3508
+ throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
3509
+ }
3510
+ if (caller && a.pub !== caller) {
3511
+ throw new Error('var.set: the envelope was signed by someone other than the caller — a relayed envelope is not accepted')
3512
+ }
3513
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3514
+ const m = (record?.members || []).find((x) => x.pub === a.pub)
3515
+ if (!record || !m) {
3516
+ throw new Error('var.set: the envelope is signed by a key the record does not name')
3517
+ }
3518
+ // UN SERVICIO SOLO PARA SU CAJÓN (dueño, 2026-09-01: «un cn no puede poner un sobre
3519
+ // faltante fuera de su cn»). Es la misma regla que ya aplica el reparto delegado
3520
+ // (`handleRewrapOk`), y tiene que valer también aquí: si no, un servicio con `admin`
3521
+ // podría escribir en el cajón de otro. Quien administra sin `cn` sí puede: ese es su
3522
+ // trabajo, y para eso se le concede a mano.
3523
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
3524
+ if (m.cn && ns && m.cn !== ns) {
3525
+ throw new Error(`var.set: "${m.cn}" cannot write into "${owner}" — a service only authors envelopes for its own drawer`)
3526
+ }
3527
+ // Y UN SERVICIO NO PISA LO QUE YA HAY (dueño, 2026-09-01: «un cn no puede sobrescribir
3528
+ // un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
3529
+ // crear los que faltan nomás»).
3530
+ //
3531
+ // SALVO QUE ALGUIEN LO APRUEBE (dueño, 2026-09-29: «si requiere aprobación hagamos que
3532
+ // pueda también editar; si no hay approval, solamente agregar — la aprobación nos da el
3533
+ // nivel de seguridad aceptable»). Un sí de quien aprueba es justo la decisión humana que
3534
+ // faltaba para reemplazar; sin él, sigue siendo solo rellenar.
3535
+ //
3536
+ // Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
3537
+ // servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
3538
+ // basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
3539
+ // quita nada a nadie; reemplazar sí, y eso es de quien administra.
3540
+ if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
3541
+ throw Object.assign(new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — without an approval a service only fills in what is missing; replacing is for who administers`), { code: 'exists' })
3542
+ }
3543
+ const cuerpo = authorBody(owner, key, sealed.e, a.ts)
3544
+ if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {
3545
+ throw new Error('var.set: the author signature does not verify over this envelope')
3546
+ }
3547
+ }
3548
+
3549
+ /**
3550
+ * ¿ESTE SOBRE ENVUELVE A QUIEN TOCA? Lo único que se puede comprobar sin la llave.
3551
+ *
3552
+ * No se puede saber si dentro de cada envoltura está la CEK correcta —para eso habría que
3553
+ * abrirla, que es justo lo que ya no se hace—, pero sí QUIÉN puede abrirlas. Y eso ya
3554
+ * ataja lo que importa: que no falte nadie (un servicio que arrancaría sin configuración)
3555
+ * y que no sobre nadie (alguien a quien el acta no nombra leyendo el cajón).
3556
+ *
3557
+ * Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
3558
+ */
3559
+ async function checkRecipients (owner, wraps, isPublic = false) {
3560
+ if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
3561
+ const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
3562
+ if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
3563
+ const deben = new Set(members.map((m) => m.pub))
3564
+ const traidos = new Set(Object.keys(wraps).filter((k) => k !== RECOVERY_WRAP))
3565
+ const faltan = [...deben].filter((p) => !traidos.has(p))
3566
+ const sobran = [...traidos].filter((p) => !deben.has(p))
3567
+ if (faltan.length) {
3568
+ throw new Error(`var.set: the envelope leaves ${faltan.length} member(s) of "${owner}" out — they could not read it (ask the vault for the recipients with \`var.recipients\`)`)
3569
+ }
3570
+ if (sobran.length) {
3571
+ throw new Error(`var.set: the envelope wraps for ${sobran.length} key(s) the record does not name for "${owner}"`)
3572
+ }
3573
+ }
3574
+
3575
+ async function refreshWraps (motivo) {
3576
+ // HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
3577
+ //
3578
+ // `startVault` sin gestor de perfiles trae `hasPassword = () => true` y `openKey =
3579
+ // null`, que leído a la letra dice «tiene contraseña pero no hay con qué abrirla» — el
3580
+ // estado imposible. Mirando solo `hasPassword` se daba por cerrada una bóveda que abre
3581
+ // con la llave de la máquina, y el repaso del llavero se saltaba ENTERO: el servicio se
3582
+ // quedaba sin su envoltura y contestábamos un paquete que no podía abrir. Es el mismo
3583
+ // fallo que se está arreglando, colado por la puerta de al lado.
3584
+ const conCandado = typeof openKey === 'function' && (() => { try { return !!hasPassword() } catch (_) { return true } })()
3585
+ const k = conCandado ? openKey() : null
3586
+ if (conCandado && !k) {
3587
+ log(`[vault] ${motivo}: the record changed but the profile key is not here — the record cannot be sealed while locked, so this should not happen; the keyring was NOT rebuilt`)
3588
+ return null
3589
+ }
3590
+ const r = await resealAll(k).catch((e) => { log(`[vault] ${motivo}: could not rebuild the keyring: ${e.message}`); return null })
3591
+ if (r?.wrapped) log(`[vault] ${motivo}: keyring rebuilt (${r.wrapped} wrap(s) in ${r.drawers} drawer(s))`)
3592
+ // LOS SOBRES CADUCOS SE DICEN. Son envolturas de quien ya no debería poder abrir ese
3593
+ // cajón: retirarlas en silencio esconde que alguien las tuvo de más.
3594
+ if (r?.dropped) log(`[vault] ${motivo}: ${r.dropped} stale wrap(s) dropped (they belonged to members the record no longer names)`)
3595
+ if (r?.failed?.length) log(`[vault] ${motivo}: ${r.failed.length} drawer(s) could NOT be resealed: ${r.failed.map((f) => f.owner).join(', ')}`)
3596
+ return r
3597
+ }
3598
+
3599
+ /**
3600
+ * MIGRACIÓN, CON FECHA: la copia de recuperación pudo quedar sellada con la llave de la
3601
+ * MÁQUINA aunque el perfil tenga contraseña.
3602
+ *
3603
+ * Pasa por un camino perfectamente normal: escribir una variable NO pide la frase (§8.1),
3604
+ * así que si el cajón se estrenó con el perfil cerrado, `ensureRecovery` la selló con lo
3605
+ * único que había — la llave de la máquina. A partir de ahí, abrir el perfil y reenvolver
3606
+ * pasa la llave de la CONTRASEÑA, que no abre ese sobre: «wrong password», el llavero no
3607
+ * se rehace, y los servicios se quedan sin poder leer sus cajones para siempre. Es
3608
+ * exactamente lo que le pasó a un perfil real el 2026-09-01.
3609
+ *
3610
+ * Aquí se abre con la de la máquina y se vuelve a cerrar con la de la contraseña, que es
3611
+ * el `rekey` que aquel `password-set` no llegó a hacer. NO es un repliegue: no decide un
3612
+ * permiso, no deja pasar a nadie, y **se acaba** — al primer desbloqueo el sobre queda
3613
+ * bajo la frase y esta rama no vuelve a entrar. Y va en el sentido estricto: el material
3614
+ * de la llave de máquina vive en este mismo disco, así que pasar a la contraseña PROTEGE
3615
+ * más de lo que había, no menos.
3616
+ *
3617
+ * Se puede quitar cuando no queden perfiles de antes de 0.81.0 (2027-03-01).
3618
+ */
3619
+ const MIGRACION_RECUPERACION_HASTA = Date.parse('2027-03-01T00:00:00Z')
3620
+ async function migrarRecuperacionALaFrase (adminKey) {
3621
+ if (!adminKey || Date.now() > MIGRACION_RECUPERACION_HASTA) return false
3622
+ try {
3623
+ // Si la frase ya la abre, no hay nada que migrar.
3624
+ await secrets.recoveryOpensWith(adminKey)
3625
+ return false
3626
+ } catch (_) {}
3627
+ try {
3628
+ // LA LLAVE DE LA MÁQUINA, EXPLÍCITA. `null` NO vale aquí: `defaultKey()` mira primero
3629
+ // `openKey()`, y con el perfil ABIERTO eso es la llave de la contraseña — o sea que
3630
+ // pasar `null` probaba la misma llave dos veces y la migración no migraba nada. Lo
3631
+ // dijo el propio diagnóstico: «no abre ni con la contraseña ni con la de la máquina»,
3632
+ // cuando la de la máquina no se había llegado a probar.
3633
+ const maquina = disk.machineKey()
3634
+ await secrets.recoveryOpensWith(maquina) // si esta tampoco, no hay migración que hacer
3635
+ const r = await secrets.rekeyRecovery(maquina, adminKey)
3636
+ if (!r?.rekeyed) return false
3637
+ log('[vault] the recovery copy was sealed with this machine\'s key: re-sealed under the profile password')
3638
+ audit('secret.recovery-rekeyed', {})
3639
+ return true
3640
+ } catch (e) {
3641
+ log(`[vault] the recovery copy does not open with the password nor with this machine's key (${e.message}): the drawers cannot be re-wrapped`)
3642
+ return false
3643
+ }
3644
+ }
3645
+
3646
+ /**
3647
+ * LAS CONTRASEÑAS, AL FORMATO SELLADO (§2.7) Y CON SUS ENVOLTURAS AL DÍA (§2.5).
3648
+ *
3649
+ * Las dos cosas se hacen aquí, al ABRIR el perfil, porque las dos necesitan lo mismo: la
3650
+ * frase. Convertir hay que hacerlo una vez; repasar las envolturas, cada vez que cambie
3651
+ * quién puede leer — y este es el único momento en que hay con qué.
3652
+ *
3653
+ * La bóveda ve los valores SOLO mientras convierte, que es cuando tiene que reescribirlos.
3654
+ * Después no puede: lo último que hace la conversión es borrar la llave vieja.
3655
+ */
3656
+ async function sealPasswords (adminKey = null) {
3657
+ if (!passwords) return null
3658
+ const recipients = {
3659
+ recoveryPub: secrets.recoveryPub(),
3660
+ main: passwordRecipients('main'),
3661
+ passkeys: passwordRecipients('passkeys')
3662
+ }
3663
+ if (!recipients.recoveryPub) {
3664
+ log('[vault] passwords: no recovery key yet — nothing can be sealed until there is one')
3665
+ return null
3666
+ }
3667
+ const out = {}
3668
+ try {
3669
+ const { convertPasswords } = await import('./passwords.js')
3670
+ const r = await convertPasswords({
3671
+ store: passwordsStore,
3672
+ sealed: passwords.sealed,
3673
+ cek: await llaveViejaDeContrasenas(),
3674
+ recipients,
3675
+ // LA MAESTRA FIRMA LO QUE ESCRIBE, como cualquier aparato que guarda una entrada.
3676
+ // Firmar no es leer: lo que se le envuelve a esta bóveda sigue siendo nada.
3677
+ author: { publickey: master, sign: (body) => identity.signData(body) },
3678
+ dropOldKey: soltarLlaveVieja,
3679
+ log
3680
+ })
3681
+ out.converted = r?.entries || 0
3682
+ if (r?.already) out.converted = 0
3683
+ } catch (e) {
3684
+ log(`[vault] passwords: could not convert to the sealed format (${e.message})`)
3685
+ out.convertError = e.message
3686
+ }
3687
+ try {
3688
+ const r = await passwords.sealed.rewrapAll({
3689
+ openRecovery: (wrap) => secrets.openRecoveryWrap(wrap, adminKey)
3690
+ })
3691
+ out.wrapped = r.wrapped
3692
+ out.dropped = r.dropped
3693
+ if (r.wrapped || r.dropped) {
3694
+ log(`[vault] passwords: keyring rebuilt (${r.wrapped} wrap(s), ${r.dropped} stale one(s) dropped)`)
3695
+ }
3696
+ if (r.failed?.length) log(`[vault] passwords: ${r.failed.length} generation(s) could NOT be rewrapped`)
3697
+ } catch (e) {
3698
+ log(`[vault] passwords: could not rebuild the keyring (${e.message})`)
3699
+ out.rewrapError = e.message
3700
+ }
3701
+ return out
3702
+ }
3703
+
3704
+ async function resealAll (adminKey = null) {
3705
+ // ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
3706
+ // máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
3707
+ if (adminKey) await migrarRecuperacionALaFrase(adminKey)
3708
+ // Y LA LLAVE DE RECUPERACIÓN EXISTE. Nacía al escribir el primer secreto, así que una
3709
+ // bóveda recién creada no tenía a quién envolver — y como crearla necesita la maestra,
3710
+ // cerrada ya no había manera. El síntoma: escribir el perfil con la bóveda cerrada
3711
+ // fallaba con «the vault did not say who to seal the profile for», en un perfil nuevo
3712
+ // y solo en el primero. Abrir la bóveda es el momento de saldarlo, y es idempotente.
3713
+ // …salvo en un almacén v3, que no tiene ese concepto: ahí lo que toca es `migrate`, y
3714
+ // escribir o leer un sobre ya se rechaza diciéndolo. Crearla antes de migrar sería
3715
+ // dejar una llave suelta en un formato que va a reescribirse entero.
3716
+ if (!secrets.isLegacy()) await secrets.ensureRecovery(adminKey)
3717
+ const out = { drawers: 0, wrapped: 0, sealed: 0, dropped: 0, sinLlave: [], failed: [] }
3718
+ for (const owner of secrets.owners?.() || []) {
3719
+ const before = new Set(secrets.recipientsIn(owner))
3720
+ // POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
3721
+ // administra. Con una sola lista se le daría también la de una privada con dueño, que
3722
+ // es justo lo que no puede pasar.
3723
+ const members = (esPublica) => recipientsOf(owner, { public: esPublica })
3724
+ try {
3725
+ const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
3726
+ const after = new Set(secrets.recipientsIn(owner))
3727
+ out.drawers++
3728
+ out.wrapped += r?.wrapped || 0
3729
+ out.sealed += r?.sealed || 0
3730
+ out.dropped += [...before].filter((p) => !after.has(p)).length
3731
+ for (const p of r?.sinLlave || []) out.sinLlave.push(p)
3732
+ } catch (e) {
3733
+ log(`[vault] ${owner}: could not reseal (${e.message})`)
3734
+ out.failed.push({ owner, error: e.message })
3735
+ }
3736
+ }
3737
+ if (out.dropped || out.sealed) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped, sealed: out.sealed })
3738
+ if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
3739
+ return out
3740
+ }
3741
+
3742
+ async function settleSecretDebts (adminKey = null) {
3743
+ const out = {}
3744
+ // Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
3745
+ // envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
3746
+ // por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
3747
+ // pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
3748
+ // que es barato y no depende de que alguien se acordara de apuntarlo.
3749
+ const debts = new Set(Object.keys(rotationsDue()))
3750
+ for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
3751
+
3752
+ for (const owner of debts) {
3753
+ const k = owner.slice(owner.indexOf(':') + 1)
3754
+ // Los destinatarios de un cajón no son solo quien lo consume: también quien lo
3755
+ // administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
3756
+ // sin poder destapar lo que ella misma acababa de saldar.
3757
+ const members = (esPublica) => recipientsOf(owner, { public: esPublica })
3758
+ try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
3759
+ // Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
3760
+ let delegated = 0
3761
+ for (const m of await incompleteMembers()) {
3762
+ if (!m.owners[owner]) continue
3763
+ delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
3764
+ }
3765
+ out[owner] = delegated ? { delegated } : { error: e.message }
3766
+ }
3767
+ }
3768
+ return out
3769
+ }
3770
+
3771
+ /**
3772
+ * Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
3773
+ * Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
3774
+ * recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
3775
+ * anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
3776
+ * guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
3777
+ */
3778
+ async function settleDebts (owner) {
3779
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
3780
+ const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
3781
+ (await incompleteMembers()).some((m) => m.owners[owner])
3782
+ if (!owed) return null
3783
+ // QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
3784
+ //
3785
+ // Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
3786
+ // se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
3787
+ // una variable envolvía para el servicio y dejaba al sellador con la generación vieja
3788
+ // (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
3789
+ // para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
3790
+ // siempre así: una se actualiza y la otra no.
3791
+ try { return await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }), null) } catch (_) { return null }
3792
+ }
3793
+ async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
3794
+
3795
+ /**
3796
+ * RENOMBRAR una variable. Para el servicio que la lee es un cambio de configuración como
3797
+ * cualquier otro —la de antes desaparece y aparece otra—, así que se avisa igual: si no,
3798
+ * seguiría arrancando con el nombre viejo hasta el siguiente reinicio.
3799
+ */
3800
+ async function renameSecret (ns, from, to) {
3801
+ const r = await secrets.rename(`ns:${ns}`, from, to)
3802
+ if (r?.renamed) { audit('secret.rename', { ns, from, to }); scheduleNotice(ns) }
3803
+ return r
3804
+ }
3805
+ async function renameDeviceSecret (pub, from, to) {
3806
+ const r = await secrets.rename(`dev:${pub}`, from, to)
3807
+ if (r?.renamed) {
3808
+ audit('secret.rename', { device: await deviceIdOf(pub).catch(() => null), from, to, scope: 'device' })
3809
+ scheduleDeviceNotice(pub)
3810
+ }
3811
+ return r
3812
+ }
3813
+
3814
+ /**
3815
+ * CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
3816
+ *
3817
+ * De una en una, cada `set` es un cambio de configuración para la bóveda, y el agente
3818
+ * obedece el primero —sale, lo levanta el supervisor, lee lo que hubiera en ese
3819
+ * instante— mientras el dueño sigue tecleando. El resultado es un servicio corriendo
3820
+ * con media configuración, y encima con el arranque a medio hacer. La ventana de
3821
+ * agrupado (`NOTICE_GROUP_MS`) tapa el caso de un script, no el de una persona
3822
+ * escribiendo con quince segundos entre variable y variable.
3823
+ *
3824
+ * Así que la carga en grupo llega hasta aquí entera: se valida TODO primero, se
3825
+ * escribe en un solo guardado y sale UN aviso al final. Las visibilidades no entran:
3826
+ * no cambian lo que el servicio lee y por eso nunca avisaron.
3827
+ *
3828
+ * @param {string} ns
3829
+ * @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
3830
+ * @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
3831
+ * estaba no cambia nada, y no tiene por qué reiniciar a nadie).
3832
+ */
3833
+ async function applySecrets (ns, items, { by = null } = {}) {
3834
+ const list = assertItems(items)
3835
+ const changed = []
3836
+ await secrets.batch(async () => {
3837
+ for (const it of list) {
3838
+ if (it.op === 'rm') {
3839
+ if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
3840
+ } else {
3841
+ await secrets.set(ns, it.key, it.value, it.public, { by })
3842
+ audit('secret.set', { ns, key: it.key })
3843
+ changed.push(it.key)
3844
+ }
3845
+ }
3846
+ })
3847
+ if (changed.length) {
3848
+ await settleDebts(`ns:${ns}`)
3849
+ scheduleNotice(ns)
3850
+ }
3851
+ return changed
3852
+ }
3853
+
3854
+ /** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
3855
+ async function applyDeviceSecrets (pub, items, { by = null } = {}) {
3856
+ const list = assertItems(items)
3857
+ const m = await requireService(pub)
3858
+ const changed = []
3859
+ await secrets.batch(async () => {
3860
+ for (const it of list) {
3861
+ if (it.op === 'rm') {
3862
+ if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
3863
+ } else {
3864
+ await secrets.setDevice(pub, it.key, it.value, it.public, { by })
3865
+ changed.push(it.key)
3866
+ }
3867
+ }
3868
+ })
3869
+ if (changed.length) {
3870
+ await settleDebts(`dev:${pub}`)
3871
+ const device = await deviceIdOf(pub).catch(() => null)
3872
+ for (const it of list) {
3873
+ if (!changed.includes(it.key)) continue
3874
+ audit(it.op === 'rm' ? 'secret.rm' : 'secret.set', { device, ns: m?.cn || null, key: it.key, scope: 'device' })
3875
+ }
3876
+ scheduleDeviceNotice(pub)
3877
+ }
3878
+ return changed
3879
+ }
3880
+
3881
+ /**
3882
+ * Todo o nada: si una variable del grupo no vale, no se escribe NINGUNA. Media
3883
+ * configuración cargada es peor que ninguna — el servicio arranca con ella.
3884
+ */
3885
+ function assertItems (items) {
3886
+ if (!Array.isArray(items) || !items.length) throw new Error('batch: no items')
3887
+ for (const it of items) {
3888
+ if (!it || (it.op !== 'set' && it.op !== 'rm')) throw new Error('batch: each item must be a set or an rm')
3889
+ if (it.op === 'rm') { if (!it.key) throw new Error('batch: rm needs a key') } else assertVar(it.key, it.value)
3890
+ }
3891
+ return items
3892
+ }
3893
+ /**
3894
+ * Los nombres, y el VALOR de las públicas. Pública quiere decir «este valor puede salir
3895
+ * de esta máquina»: taparlo justo aquí —en la máquina donde vive, delante de su dueño—
3896
+ * era lo único que la marca no significaba. La consola remota ya las enseña.
3897
+ */
3898
+ /**
3899
+ * LE PONE A CADA PÚBLICA SU SOBRE Y LA ENVOLTURA DE QUIEN PREGUNTA, para que pueda leerla.
3900
+ *
3901
+ * Solo a las públicas: es la política de despacho, y es lo único que cambia entre una y
3902
+ * otra. A una privada no se le manda nada — y aunque se le mandara, quien administra no
3903
+ * tiene su envoltura en un cajón con dueño, así que no la abriría igual.
3904
+ *
3905
+ * Si falta la envoltura de quien pregunta (todavía no se le ha hecho, o el cajón se
3906
+ * escribió antes de que entrara) se manda la variable SIN sobre: se ve que existe y que es
3907
+ * pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
3908
+ * un valor o esconder la fila sería peor.
3909
+ */
3910
+ const withEnvelopes = (entries, kind, caller) => {
3911
+ if (!caller) return entries
3912
+ const attach = (owner, k) => {
3913
+ if (!k.public) return k
3914
+ const e = secrets.entryOf?.(owner, k.key)
3915
+ const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
3916
+ return e?.e && wrap ? { ...k, e: e.e, wrap } : k
3917
+ }
3918
+ if (kind === 'ns') {
3919
+ const out = {}
3920
+ for (const [ns, keys] of Object.entries(entries)) out[ns] = keys.map((k) => attach(`ns:${ns}`, k))
3921
+ return out
3922
+ }
3923
+ return entries.map((row) => ({ ...row, keys: row.keys.map((k) => attach(`dev:${row.pub}`, k)) }))
3924
+ }
3925
+
3926
+ function listSecrets () {
3927
+ // YA NO SE ENSEÑA EL VALOR DE UNA PÚBLICA (dueño, 2026-09-02). Desde que todas van en
3928
+ // sobre, «pública» dejó de significar «en claro»: dice a quién se le despacha sin
3929
+ // aprobación, nada más. Para ver un valor hay que poder abrirlo, igual que cualquiera —
3930
+ // y en un cajón con dueño, quien administra deliberadamente no puede.
3931
+ return secrets.list()
3932
+ }
3933
+ /** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
3934
+ async function setSecretVisibility (ns, key, isPublic, adminKey) {
3935
+ const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
3936
+ if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
3937
+ return ok
3938
+ }
3939
+
3940
+ /**
3941
+ * El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
3942
+ * camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
3943
+ * este sí pide poder abrir la copia maestra.
3944
+ */
3945
+ async function openSecrets (ns, devicePub = null, adminKey) {
3946
+ return secrets.openBundle(ns, devicePub, adminKey)
3947
+ }
3948
+
3949
+ /**
3950
+ * CAMBIA LOS PERMISOS DE UNO O VARIOS APARATOS EN UNA SOLA ACTA. `changes`: `[{ pub, caps }]`,
3951
+ * cada `caps` la lista COMPLETA de ese aparato.
3952
+ */
3953
+ async function setCapsMany (changes) {
3954
+ if (!Array.isArray(changes) || !changes.length) throw Object.assign(new Error('no changes to save'), { code: 'NO_CHANGES' })
3955
+ if (typeof identity.setCapsMany !== 'function') {
3956
+ throw Object.assign(new Error('this build\'s @dotrino/identity cannot change several devices at once (needs setCapsMany)'), { code: 'caps-many-unavailable' })
3957
+ }
3958
+ const r = await identity.setCapsMany(changes)
3959
+ // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
3960
+ // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
3961
+ // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
3962
+ // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
3963
+ // contenedor, porque la imagen traía la versión de npm y el árbol local otra.
3964
+ try {
3965
+ const members = (await identity.profileMembers()).members
3966
+ for (const { pub, caps } of changes) {
3967
+ const quedaron = new Set(members.find((m) => m.pub === pub)?.caps || [])
3968
+ const perdidos = caps.filter((c) => !quedaron.has(c))
3969
+ if (perdidos.length) {
3970
+ log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
3971
+ }
3972
+ }
3973
+ } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
3974
+ const devices = await Promise.all(changes.map(async ({ pub, caps }) => ({ deviceId: await deviceIdOf(pub).catch(() => null), caps })))
3975
+ for (const d of devices) audit('caps', { device: d.deviceId, caps: d.caps })
3976
+ // El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
3977
+ // avisar, para que el aviso salga con el llavero ya al día.
3978
+ await refreshWraps('caps')
3979
+ // UN aviso: un aparato solo (la forma de siempre) o la lista, si fueron varios.
3980
+ await notifyMembers('caps', devices.length === 1 ? devices[0] : { devices })
3981
+ return r
3982
+ }
3983
+
3984
+ /** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
3985
+ async function memberOf (pub) {
3986
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
3987
+ if (!record) return null
3988
+ return (record.members || []).find((m) => m.pub === pub) || null
3989
+ }
3990
+
3991
+ /**
3992
+ * Variables de UN aparato. Se exige que sea un SERVICIO del acta (miembro con `cn`) porque
3993
+ * es el único que las lee: guardárselas a un teléfono sería configuración muerta, escrita
3994
+ * donde nadie la va a buscar el día que no funcione. Si la bóveda es anterior al acta no
3995
+ * hay contra qué comprobarlo y se acepta.
3996
+ */
3997
+ async function requireService (pub) {
3998
+ const m = await memberOf(pub)
3999
+ if (!m) {
4000
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
4001
+ if (record) throw new Error('device: it is not a member of this profile')
4002
+ return null
4003
+ }
4004
+ if (!m.cn) throw new Error('device: it is not a service (only services read variables); pair it with `pair --service <ns>`')
4005
+ return m
4006
+ }
4007
+
4008
+ async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
4009
+ const m = await requireService(pub)
4010
+ await secrets.setDevice(pub, key, value, isPublic, { by })
4011
+ await settleDebts(`dev:${pub}`)
4012
+ audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
4013
+ scheduleDeviceNotice(pub)
4014
+ }
4015
+
4016
+ async function deleteDeviceSecret (pub, key) {
4017
+ const m = await memberOf(pub)
4018
+ const ok = await secrets.deleteDevice(pub, key)
4019
+ if (ok) {
4020
+ audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
4021
+ scheduleDeviceNotice(pub)
4022
+ }
4023
+ return ok
4024
+ }
4025
+
4026
+ async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
4027
+ const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
4028
+ if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
4029
+ return ok
4030
+ }
4031
+
4032
+ /**
4033
+ * Las variables por aparato —nombres, y el valor de las PÚBLICAS, igual que `listSecrets`—
4034
+ * con quién es cada aparato pegado: una llave suelta no se puede administrar. `orphan`
4035
+ * marca las que quedaron de una llave que ya no está en el acta.
4036
+ */
4037
+ async function listDeviceSecrets () {
4038
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
4039
+ const members = record?.members || []
4040
+ const out = []
4041
+ for (const [pub, keys] of Object.entries(secrets.listDevices())) {
4042
+ const m = members.find((x) => x.pub === pub) || null
4043
+ out.push({
4044
+ pub,
4045
+ id: m?.id || await deviceIdOf(pub).catch(() => null),
4046
+ label: m?.label || '',
4047
+ cn: m?.cn || null,
4048
+ // Sin valores: desde 2026-09-02 una pública también va en sobre (ver `listSecrets`).
4049
+ keys,
4050
+ orphan: !!members.length && !m
4051
+ })
4052
+ }
4053
+ return out
4054
+ }
4055
+
4056
+ return {
4057
+ identity, client, store, threads, secrets, master, fingerprint: fp, dir: disk.dir || null,
4058
+ // Alcanzable ≠ corriendo: si el proxio rechazó el `identify` esta bóveda está viva y
4059
+ // nadie puede hablarle. Lo mira `status` (ver `manager.js`, `summary`).
4060
+ isIdentified,
4061
+ // Se expone para poder PROBAR que una respuesta que no cabe no mata la conexión: es el
4062
+ // único punto por el que sale todo, y el fallo que cierra vive justo ahí.
4063
+ reply,
4064
+ /**
4065
+ * SOLTAR LA MAESTRA. Cerrar el perfil tiene que sacarla de la memoria, no solo dejar de
4066
+ * cargarla en el siguiente arranque: si no, el candado seguiría siendo una bandera al
4067
+ * lado de una llave descifrada, que es exactamente lo que se quitó de en medio.
4068
+ *
4069
+ * Se recarga el par: sin la llave del perfil el pilar lo deja SIN privada, así que a
4070
+ * partir de aquí firmar se niega con `vault-locked`. Servir sigue igual — eso lo hace la
4071
+ * llave de comunicación, que no depende de la contraseña.
4072
+ */
4073
+ async dropMasterKey () {
4074
+ const r = await identity.reloadMasterKey?.()
4075
+ return { locked: r?.locked !== false }
4076
+ },
4077
+ /** Al abrir: recuperar la maestra y, si venía en claro de antes, dejarla sellada. */
4078
+ async takeMasterKey () {
4079
+ const r = await identity.reloadMasterKey?.()
4080
+ if (r?.locked === false) { try { await identity.sealMasterKey?.() } catch (_) {} }
4081
+ // ABRIR ES EL ÚNICO MOMENTO en que un perfil con contraseña está abierto, así que es
4082
+ // aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
4083
+ // Si falla, no se arrastra: abrir el perfil no puede depender de esto.
4084
+ //
4085
+ // Y SE VUELVE A IDENTIFICAR EN EL ACTO: `identify` solo corre al conectar y al cambiar
4086
+ // el token, así que estrenar la llave no servía de nada hasta el siguiente arranque —
4087
+ // la bóveda seguía muda en el proxio con su llave ya admitida en el acta. Al dueño le
4088
+ // pasó el 2026-09-05: hizo lo que el log pedía y la consola siguió sin contestar.
4089
+ if (r?.locked === false) {
4090
+ try { if (await ensureCommKeyInActa()) await reidentificar?.() } catch (_) {}
4091
+ }
4092
+ // Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
4093
+ // eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
4094
+ // el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
4095
+ // puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
4096
+ if (r?.locked === false) { try { await absorberSubacta('unlock') } catch (_) {} }
4097
+ // Y LOS APARATOS MUERTOS FUERA DEL ACTA, en una sola (dueño, 2026-09-22). Es el mismo
4098
+ // momento: la maestra está en memoria y cambiar el acta es uno de sus dos trabajos.
4099
+ // Si falla no se arrastra —abrir no depende de esto—, pero se dice.
4100
+ if (r?.locked === false) {
4101
+ try {
4102
+ const antes = await refreshActa()
4103
+ const aprobaban = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
4104
+ const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
4105
+ for (const d of removed) {
4106
+ const id = await deviceIdOf(d.pub).catch(() => null)
4107
+ audit('pruned', { device: id, label: d.label || '', reason: 'expired', seq })
4108
+ log(`[vault] unlock: removed ${id || '????-????'} ${d.label ? `(${d.label}) ` : ''}from the record: its certificate expired and cannot be renewed · record #${seq}`)
4109
+ }
4110
+ // ¿SE LLEVÓ AL ÚLTIMO QUE APROBABA? Entonces se dice ALTO. Quitarlo es correcto —con
4111
+ // el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
4112
+ // pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
4113
+ // el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
4114
+ const despues = await refreshActa()
4115
+ const quedan = (despues?.members || []).filter((m) => Acta.memberCan(despues, m.pub, 'approve')).map((m) => m.pub)
4116
+ const idosPub = lastApproverGone(aprobaban, quedan, removed.map((d) => d.pub))
4117
+ const idos = removed.filter((d) => idosPub.includes(d.pub))
4118
+ if (idos.length) {
4119
+ const quienes = (await Promise.all(idos.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
4120
+ audit('no-approver', { removed: idos.length, seq })
4121
+ log(`[vault] WARNING: nobody in the record can approve any more — ${quienes} was the last. From now on every device gets its keys WITHOUT approval, until you enrol one and grant it \`dotrino-vault caps <ID> +aprueba\``)
4122
+ }
4123
+ } catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
4124
+ }
4125
+ // Y DESPUÉS, LOS SOBRES A LA LLAVE VIGENTE: la poda de arriba pudo rotarla.
4126
+ if (r?.locked === false) {
4127
+ try { await resealStaleThreads('unlock') } catch (e) { log(`[vault] unlock: could not reseal old envelopes: ${e.message}`) }
4128
+ }
4129
+ return { locked: r?.locked !== false }
4130
+ },
4131
+ // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
4132
+ // de que justo acabe de cambiar algo.
4133
+ pushToReplicas,
4134
+ startPairing: desk.startPairing,
4135
+ stopPairing: desk.stopPairing,
4136
+ listPending: desk.listPending,
4137
+ // ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
4138
+ // (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
4139
+ // `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
4140
+ needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
4141
+ /**
4142
+ * QUIÉN PUEDE APROBAR EN ESTA CUENTA. Lo usa la actualización (§15): la bóveda pide
4143
+ * permiso al teléfono **solo si hay a quién pedírselo**; si no hay ningún aparato con
4144
+ * `aprueba`, se actualiza sola.
4145
+ *
4146
+ * Es la regla del dueño (2026-09-20) y evita la trampa evidente: una bóveda sin
4147
+ * aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
4148
+ * puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
4149
+ */
4150
+ /**
4151
+ * PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
4152
+ * un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
4153
+ * un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
4154
+ * @returns {{ answer: Promise<boolean>, withdraw: () => void }} `answer`: sí o no (vencido
4155
+ * o retirado es no). `withdraw` quita el pedido si sigue vivo.
4156
+ */
4157
+ askUpdateApproval: ({ version }) => {
4158
+ const yo = identity.me?.publickey || null
4159
+ let pendId = null
4160
+ let withdrawn = false
4161
+ let settle
4162
+ const answer = new Promise((resolve) => { settle = resolve })
4163
+ // RETIRAR: otro perfil ya contestó por la máquina, o ya no hace falta. El pedido sale
4164
+ // de la lista de quien aprueba en vez de quedarse un día pidiendo algo ya decidido.
4165
+ const retirar = () => {
4166
+ if (!pendId || !waiters.delete(pendId)) return
4167
+ const pend = approvals.take(pendId)
4168
+ audit('update.withdrawn', { device: pend?.deviceId || null, ns: 'vault', id: pendId })
4169
+ log(`[vault] update: request ${pendId} withdrawn`)
4170
+ settle(false)
4171
+ }
4172
+ awaitApproval({
4173
+ device: yo, ns: 'vault', kind: 'update', what: 'update',
4174
+ ctx: { product: '@dotrino/vaultd', version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: settle
4175
+ }).then((pend) => { pendId = pend.id; if (withdrawn) retirar() })
4176
+ .catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); settle(false) })
4177
+ return { answer, withdraw: () => { withdrawn = true; retirar() } }
4178
+ },
4179
+ announceUpdate,
4180
+ approvers: async () => {
4181
+ const record = await refreshActa()
4182
+ if (!record) return []
4183
+ return (record.members || [])
4184
+ .filter((m) => Acta.memberCan(record, m.pub, 'approve'))
4185
+ // `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
4186
+ // dicho, o caducó). No decide si se pide: se enseña junto al pedido sin respuesta.
4187
+ .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
4188
+ },
4189
+ // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
4190
+ // llave. Lo que no puede es listarla un aparato.
4191
+ passwordDevices,
4192
+ passwordsVault: () => passwords?.vault || null,
4193
+ // La política de las contraseñas, para quien atiende su propio almacén (la extensión).
4194
+ passwordsNeedApproval,
4195
+ approvePasswordRequest,
4196
+ // --- ENTRAR CON USUARIO Y CONTRASEÑA -------------------------------------------------
4197
+ //
4198
+ // El alta va en DOS pasos porque la contraseña no pasa por aquí: quien crea el aparato
4199
+ // (la CLI, en esta máquina) hace su mitad de OPAQUE, genera las llaves del aparato y las
4200
+ // cierra con la llave que sale de la contraseña. La bóveda solo guarda lo que no puede
4201
+ // abrir, sella el acta y firma el certificado — y eso último sí pide el perfil abierto.
4202
+ listLogins: () => logins.list(),
4203
+ loginRegisterBegin: ({ user, request, replace = false }) => logins.registerBegin({ user, request, replace }),
4204
+ // El alta es la MISMA pieza que usa la bóveda-pestaña (`registerLogin`): aquí solo se
4205
+ // añade lo que es del binario — la bitácora y el aviso a los demás aparatos.
4206
+ loginRegisterFinish: async (opts) => {
4207
+ const r = await registerLogin({ ...opts, identity, logins })
4208
+ if (r.replaced) { audit('login.passwd', { user: r.user }); return r }
4209
+ audit('login.create', { user: r.user, device: r.deviceId, caps: r.caps })
4210
+ await notifyMembers('enrolled', { deviceId: r.deviceId, by: 'login' })
4211
+ return r
4212
+ },
4213
+ // ENTRAR DESDE ESTA MISMA MÁQUINA. Es el mismo intercambio que hace un navegador por el
4214
+ // proxio, y pasa por el mismo freno: lo usa `logins passwd`, que necesita abrir el
4215
+ // paquete de llaves con la contraseña vieja antes de volver a cerrarlo con la nueva.
4216
+ loginBegin: ({ user, request }) => logins.loginBegin({ user, request }),
4217
+ loginEnd: ({ lid, finalization, label }) => logins.loginEnd({ lid, finalization, label }),
4218
+ closeLogin: ({ user, sid }) => {
4219
+ const r = logins.closeSession({ user, sid })
4220
+ if (r.ok) audit('login.close', { user, sid: String(sid).slice(0, 8), by: 'console' })
4221
+ return r
4222
+ },
4223
+ clearLoginBlock: ({ user }) => logins.clearBlock({ user }),
4224
+ removeLogin: async ({ user }) => {
4225
+ const found = logins.list().find((x) => x.user === user)
4226
+ if (!found) return { ok: false }
4227
+ logins.remove({ user })
4228
+ audit('login.remove', { user, device: found.deviceId })
4229
+ // Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
4230
+ return { ok: true, pub: found.pub, deviceId: found.deviceId }
4231
+ },
4232
+ listApprovals: () => approvals.list(),
4233
+ // El perfil desde el cajón `@me` (lo que empujan los aparatos): ver `profileView`.
4234
+ profileView: () => secrets.profileView(),
4235
+ // LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
4236
+ // dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
4237
+ listGrants: () => grants.list(),
4238
+ revokeGrant: (id) => grants.revoke(id),
4239
+ revokeGrants: (filtro) => grants.revokeAll(filtro || {}),
4240
+ // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
4241
+ // dispositivos se entera de que entró alguien, venga de donde venga.
4242
+ approveDevice: async (code, adminKey) => {
4243
+ const r = await desk.approve(code)
4244
+ // Un servicio que ENTRA a un namespace que ya tiene variables necesita su
4245
+ // envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
4246
+ // reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
4247
+ // puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
4248
+ // el archivo vendorizado en el iframe de identidad.
4249
+ const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
4250
+ if (m?.cn) {
4251
+ // POR `recipientsOf`, NO por `nsMembers`. Quien debe tener envoltura de un cajón lo
4252
+ // dice UN sitio, y ahí dentro están también los SELLADORES (dueño, 2026-09-01: «al
4253
+ // envolver hay que envolver siempre para todos los selladores, para que siempre
4254
+ // tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
4255
+ // puerta dejaba a los selladores con la envoltura vieja.
4256
+ await spreadKey(`ns:${m.cn}`, (esPublica) => recipientsOf(`ns:${m.cn}`, { public: esPublica }), adminKey).catch(async (e) => {
4257
+ log('[vault] could not hand the key to the new service:', e.message)
4258
+ // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
4259
+ // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
4260
+ // recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
4261
+ // a la vista y se salda al abrir la bóveda.
4262
+ const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
4263
+ if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
4264
+ })
4265
+ }
4266
+ // Y el llavero ENTERO: el recién llegado también es destinatario de su propio cajón,
4267
+ // y si entra con `sella` lo es de TODOS. Repartir solo el suyo dejaba el resto atrás.
4268
+ await refreshWraps('enrolled')
4269
+ await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
4270
+ return r
4271
+ },
4272
+ rejectDevice: (deviceId) => desk.reject(deviceId),
4273
+ // El mostrador que atiende a la consola remota. Se expone para poder probar la
4274
+ // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
4275
+ vars: varsDesk,
4276
+ setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
4277
+ // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
4278
+ // por eso es una operación con nombre propio, no algo que ocurra de refilón al
4279
+ // desbloquear: deja `secrets.json.v3.bak` para poder volver.
4280
+ /**
4281
+ * Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
4282
+ * normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
4283
+ * cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
4284
+ *
4285
+ * Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
4286
+ * entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
4287
+ * diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
4288
+ */
4289
+ resealAll,
4290
+ /** Convierte las contraseñas al formato sellado y repasa sus envolturas (§2.5, §2.7). */
4291
+ sealPasswords,
4292
+ delegateRewrap,
4293
+ incompleteMembers,
4294
+ migrateSecrets: (membersOf, adminKey) => secrets.migrate(
4295
+ membersOf || ((owner) => recipientsOf(owner)), adminKey
4296
+ ),
4297
+ settleSecretDebts,
4298
+ revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
4299
+ secretHistory: (owner, key) => secrets.history(owner, key),
4300
+ revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
4301
+ revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
4302
+ // Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
4303
+ // la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
4304
+ rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
4305
+ setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
4306
+ applySecrets, applyDeviceSecrets,
4307
+ listDevices: () => identity.listDelegations(),
4308
+ // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
4309
+ // `dotrino-vault members` y la consola de vault.dotrino.com.
4310
+ /**
4311
+ * Los miembros del acta, y QUÉ CORRE CADA UNO (CONVENCIONES §14). La versión no está en
4312
+ * el acta —el acta dice quién puede qué, no qué build es— así que se le pega aquí, que
4313
+ * es por donde salen `members` y la consola. El dueño lo pidió en los dos sitios.
4314
+ *
4315
+ * `running: null` es «todavía no ha hablado desde que arrancamos», que no es lo mismo
4316
+ * que «no declara»: lo segundo trae un dictamen con `code: 'undeclared'`.
4317
+ */
4318
+ profileMembers: async () => {
4319
+ const r = await identity.profileMembers()
4320
+ return {
4321
+ ...r,
4322
+ vault: MI_VERSION,
4323
+ // Y LO QUE RENUNCIÓ Y NO SE HA SELLADO. Ya surte efecto —de eso vive la subacta—
4324
+ // pero si no se ve, el dueño mira la lista, lee los permisos del acta y cree que
4325
+ // ese aparato los tiene. Un freno que no se explica es otro silencio.
4326
+ members: (r?.members || []).map((m) => ({
4327
+ ...m,
4328
+ running: versionDe(m.pub),
4329
+ // Dónde corre: lo último que dijo firmado y, si nunca habló, lo del acta.
4330
+ machine: machines.of(m.pub) || m.machine || null,
4331
+ // Cuándo dijo que le llegan los avisos (null = no lo ha dicho, o caducó). Solo
4332
+ // tiene sentido en quien aprueba, y es un dato para enseñar: no decide nada.
4333
+ notifiedAt: notifiable.since(m.pub),
4334
+ renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
4335
+ }))
4336
+ }
4337
+ },
4338
+ // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
4339
+ // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
4340
+ rotationsDue,
4341
+ secretDebts,
4342
+ // LOS BLOQUEADOS, y quitarlos de la lista (`dotrino-vault block|unblock <ID>`).
4343
+ listBlocked: () => blocked.list(),
4344
+ setBlocked,
4345
+ /**
4346
+ * QUIÉN tiene envoltura de la generación vigente de un cajón (llaves de firma, más
4347
+ * `#recovery`). Es diagnóstico: saber a cuántos se les envolvió no ayuda a abrir nada,
4348
+ * y en cambio es lo único que contesta de verdad a «¿quién puede leer esto?».
4349
+ *
4350
+ * Se expone porque hacía falta para COMPROBARLO en una prueba en vez de argumentarlo:
4351
+ * la condición del dueño para acotar los sobres por cajón y permisos —en vez de
4352
+ * envolver para todos— fue estar seguro de que no falta ninguno.
4353
+ */
4354
+ secretRecipients: (owner) => secrets.recipientsIn(owner),
4355
+ // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
4356
+ isMaster: () => identity.isMaster(),
4357
+ setCaps: async (pub, caps) => setCapsMany([{ pub, caps }]),
4358
+ // VARIOS APARATOS EN UNA SOLA ACTA (dueño, 2026-09-26): el borrador de la TUI se sella
4359
+ // de golpe, con UN repaso del llavero y UN aviso, en vez de uno por aparato.
4360
+ setCapsMany,
4361
+ // Renombrar un dispositivo: es un nombre para el humano (no toca permisos ni llaves),
4362
+ // pero pasa por el acta y se avisa, para que el cambio no sea invisible en el resto.
4363
+ setLabel: async (pub, label) => {
4364
+ const r = await identity.setLabel(pub, label)
4365
+ const device = await deviceIdOf(pub).catch(() => null)
4366
+ audit('label', { device, label: r?.label ?? label })
4367
+ await notifyMembers('label', { deviceId: device, label: r?.label ?? label })
4368
+ return r
4369
+ },
4370
+ // QUITAR EL DISPOSITIVO. Se identifica por su llave (`sub`), no por un `nonce`: un
4371
+ // aparato puede tener varios certificados y retirar uno no lo echaba de la bóveda.
4372
+ // Se sigue aceptando `{ nonce }` para no romper una consola vieja en vuelo.
4373
+ //
4374
+ // Al salir alguien el acta cambia igual que al entrar, así que el llavero se repasa
4375
+ // también aquí: lo que sobra se quita (`resealAll` lo hace) y el que se queda sigue
4376
+ // completo. Un llavero que solo se repasa al añadir acumula envolturas de quien ya no
4377
+ // está, que es lo contrario de lo que se quiso al expulsarlo.
4378
+ /**
4379
+ * UN CANAL LOCAL, para quien corre en el mismo proceso (la consola de la pestaña que ES
4380
+ * la bóveda). Es el mostrador local del binario sin socket: el mismo enrutador, y cada
4381
+ * pedido se comprueba igual que si llegara por el proxio. `onReply` recibe cada respuesta.
4382
+ */
4383
+ localChannel (onReply) {
4384
+ const id = 'local:' + Math.random().toString(36).slice(2)
4385
+ canales.set(id, (obj) => { try { onReply(obj) } catch (_) {} })
4386
+ return {
4387
+ send: (payload) => { onMessage(id, payload).catch((e) => reply(id, { type: MSG.ERROR, error: e.message })) },
4388
+ close: () => { canales.delete(id) }
4389
+ }
4390
+ },
4391
+ revokeDevice: async (target) => {
4392
+ const sub = typeof target === 'object' && target ? target.sub : null
4393
+ if (!sub) {
4394
+ const nonce = typeof target === 'string' ? target : target?.nonce
4395
+ const r = await desk.revoke(nonce)
4396
+ await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
4397
+ return r
4398
+ }
4399
+ const r = await desk.revokeDevice(sub)
4400
+ audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
4401
+ await refreshWraps('revoked')
4402
+ // Quitar un aparato rota la llave de la cuenta: lo guardado pasa a la nueva.
4403
+ try { await resealStaleThreads('revoke') } catch (e) { log(`[vault] revoke: could not reseal old envelopes: ${e.message}`) }
4404
+ await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
4405
+ return r
4406
+ },
4407
+ close () {
4408
+ clearInterval(approvalsSweeper)
4409
+ for (const t of pendingNotices.values()) clearTimeout(t)
4410
+ pendingNotices.clear()
4411
+ try { client.close() } catch (_) {} identity.destroy()
4412
+ }
4413
+ }
4414
+ }