@dotrino/vaultd 0.155.0 → 0.157.0

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