@dotrino/vaultd 0.53.0 → 0.56.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/profiles.js CHANGED
@@ -24,7 +24,9 @@ import fs from 'node:fs'
24
24
  import crypto2 from 'node:crypto'
25
25
  import path from 'node:path'
26
26
  import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
27
- import { atRestFor, migrateFile, machineKey } from './atrest.js'
27
+ import { atRestFor, migrateFile, kekFor } from './atrest.js'
28
+ import { probe as probeKek, writeConfig as writeKekConfig, configFromEnv } from './atrest.js'
29
+ import { keyDirName, keyOwnerOf } from './keyowner.js'
28
30
 
29
31
  const REGISTRY = 'profiles.json'
30
32
  const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
@@ -107,7 +109,16 @@ async function derivePwd (password, saltB64, iter) {
107
109
  return b64(bits)
108
110
  }
109
111
 
110
- const newId = () => 'p' + crypto.randomUUID().slice(0, 8)
112
+ /**
113
+ * El nombre de la carpeta DE PASO, la que existe solo mientras se acuña la llave.
114
+ *
115
+ * La llave se genera antes de escribirse (`crypto.subtle.generateKey` y luego el kv), pero
116
+ * la única API que tenemos —`Identity.connect({ dir })`— recibe la carpeta por delante y
117
+ * hace las dos cosas de un tirón: no hay costura donde meterse a preguntar la huella. Así
118
+ * que se acuña aquí y en cuanto se sabe de quién es, la carpeta se MUEVE a su nombre.
119
+ * El punto delante la deja fuera de la lista de perfiles si algo se corta a medias.
120
+ */
121
+ const stagingName = () => '.new-' + crypto.randomUUID().slice(0, 8)
111
122
  const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
112
123
 
113
124
  /**
@@ -125,7 +136,22 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
125
136
  // respaldo o en una carpeta compartida por descuido, que es lo que el códec cubre
126
137
  // para el resto. La migración verifica antes de reemplazar y es de una sola vez.
127
138
  ensureDir(root)
128
- try { migrateFile(file, machineKey(root)) } catch (_) {}
139
+ // LA RAÍZ TAMBIÉN NECESITA EL PROVEEDOR DEL ENTORNO, y esto lo destapó un contenedor:
140
+ // los perfiles nacían con la clave en el KMS, pero el REGISTRO de perfiles seguía con
141
+ // la de la máquina — así que al recrear el contenedor la bóveda no arrancaba igual,
142
+ // solo que fallando un paso antes. No guarda el contenido de ningún perfil (la lista y
143
+ // el verificador del candado), pero sin él no hay bóveda.
144
+ //
145
+ // Solo si está por estrenar: si el registro ya existe cifrado con la clave de antes,
146
+ // cambiarle el proveedor lo dejaría ilegible. Ahí se migra con `atrest rekey`.
147
+ if (!fs.existsSync(path.join(root, 'atrest.json'))) {
148
+ const kek = configFromEnv()
149
+ if (kek && !fs.existsSync(file)) {
150
+ probeKek(root, kek)
151
+ writeKekConfig(root, kek)
152
+ }
153
+ }
154
+ try { migrateFile(file, kekFor(root)) } catch (_) {}
129
155
  const atRest = atRestFor(root)
130
156
  let data = readJson(file, null, atRest)
131
157
  if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
@@ -192,6 +218,13 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
192
218
  const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
193
219
  if (hits.length === 1) return hits[0].id
194
220
  if (hits.length > 1) throw new Error(`there are ${hits.length} profiles named "${ref}"; use its id (dotrino-vault profile ls)`)
221
+ // POR PREFIJO. Desde que el id del perfil es el nombre de su carpeta —y ese sale de
222
+ // la llave— es largo, y nadie va a teclear 24 caracteres para decir `--profile`. El
223
+ // trozo que sí se lee y se reconoce es la huella de delante (`0571-465F`), que es la
224
+ // misma que sale en `members`. Ambiguo se rechaza, no se adivina.
225
+ const porPrefijo = data.profiles.filter((p) => p.id.toLowerCase().startsWith(needle))
226
+ if (porPrefijo.length === 1) return porPrefijo[0].id
227
+ if (porPrefijo.length > 1) throw new Error(`"${ref}" matches ${porPrefijo.length} profiles; give more of the id (dotrino-vault profile ls)`)
195
228
  throw new Error('profile does not exist: ' + ref)
196
229
  },
197
230
 
@@ -201,12 +234,18 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
201
234
  * navegador, que adopta la identidad vieja como «Perfil 1»). `transport.json`
202
235
  * se queda en la raíz: es del proceso, no de la identidad.
203
236
  */
204
- migrate () {
237
+ async migrate (mintKey) {
205
238
  if (data.profiles.length) return null
206
239
  const legacy = fs.existsSync(path.join(root, 'identity.json'))
207
- const id = newId()
208
- const dir = dirOf(id)
209
- ensureDir(dir)
240
+ // EL PROVEEDOR DEL ENTORNO, y SOLO en una instalación nueva. Es lo que hace que un
241
+ // contenedor levantado con `DOTRINO_KMS_KEY_ID` tenga su primer perfil ya con la
242
+ // clave en el KMS, sin entrar a configurar nada.
243
+ //
244
+ // En una MIGRACIÓN no se toca, y el matiz importa: esos archivos ya están cifrados
245
+ // con la clave de la máquina, así que ponerles ahora un proveedor distinto los
246
+ // dejaría ilegibles —el guardia lo pararía, pero el usuario se quedaría con una
247
+ // bóveda que no arranca tras actualizar—. Ahí se migra con `atrest rekey`, a la vista.
248
+ const dir = api.stage({ fromEnv: !legacy })
210
249
  if (legacy) {
211
250
  for (const f of LEGACY_FILES) {
212
251
  const from = path.join(root, f)
@@ -217,12 +256,23 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
217
256
  if (/^peers\..+\.json$/.test(f)) { try { fs.renameSync(path.join(root, f), path.join(dir, f)) } catch (_) {} }
218
257
  }
219
258
  }
220
- // «Perfil 1» tanto al migrar como en una instalación nueva: es un nombre que
221
- // el dueño puede cambiar, y evita que la CLI salude con «(sin nombre)».
222
- data.profiles.push({ id, name: 'Perfil 1', createdAt: Date.now() })
223
- data.current = id
224
- save()
225
- return { id, migrated: legacy }
259
+ try {
260
+ // Al migrar, la llave ya existe y esto solo la lee; en una instalación nueva, la crea.
261
+ const pub = await mintKey(dir)
262
+ // «Perfil 1» tanto al migrar como en una instalación nueva: es un nombre que
263
+ // el dueño puede cambiar, y evita que la CLI salude con «(sin nombre)».
264
+ const p = await api.commit(dir, 'Perfil 1', { pub })
265
+ data.current = p.id
266
+ save()
267
+ return { id: p.id, migrated: legacy }
268
+ } catch (e) {
269
+ // En una instalación NUEVA no hay nada que perder y la carpeta de paso se va. En
270
+ // una MIGRACIÓN sí lo hay —los archivos del dueño ya están dentro—, así que se
271
+ // queda donde está y se dice en voz alta: borrarla sería borrar su bóveda.
272
+ if (!legacy) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {} }
273
+ else e.message += ` (your data is safe in ${dir}; it could not be given its final name)`
274
+ throw e
275
+ }
226
276
  },
227
277
 
228
278
  /**
@@ -233,15 +283,110 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
233
283
  * leería como pisar una cuenta con datos y se rechazaría, que es lo que tiene que
234
284
  * pasar cuando nadie lo pidió.
235
285
  */
236
- add (name, { adopt = false } = {}) {
237
- const id = newId()
238
- ensureDir(dirOf(id))
286
+ /**
287
+ * `kek`: el proveedor de la clave del disco, si el perfil tiene que NACER con él.
288
+ *
289
+ * Se escribe aquí y no después porque ese es justo el momento que importa: el
290
+ * directorio existe y todavía no hay un solo byte dentro. La maestra que genere
291
+ * `open()` un instante más tarde ya nace bajo esa clave y NUNCA existe bajo otra.
292
+ *
293
+ * Migrar un perfil que ya tiene identidad NO da esto, y por eso no se ofrece como
294
+ * si lo diera: su maestra ya se escribió bajo la clave vieja, y una copia del disco
295
+ * anterior a la migración la sigue abriendo para siempre. Un perfil con raíz en el
296
+ * KMS **nace** así (dueño, 2026-08-30).
297
+ */
298
+ stage ({ kek = null, fromEnv = true } = {}) {
299
+ const dir = path.join(root, 'p', stagingName())
300
+ ensureDir(dir)
301
+ // Sin `--kms` explícito, manda el entorno: es lo que permite levantar un contenedor
302
+ // con KMS pasando variables, sin entrar a escribir un JSON dentro. Solo al CREAR:
303
+ // un perfil que ya existe manda con su `atrest.json`.
304
+ kek = kek || (fromEnv ? configFromEnv() : null)
305
+ if (kek) {
306
+ // Comprobar ANTES de dejar rastro: un KMS que no responde no puede dejar a
307
+ // medio crear un perfil cuya maestra no se va a poder volver a abrir.
308
+ try {
309
+ probeKek(dir, kek)
310
+ writeKekConfig(dir, kek)
311
+ } catch (e) {
312
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
313
+ throw e
314
+ }
315
+ }
316
+ return dir
317
+ },
318
+
319
+ /**
320
+ * La carpeta de paso ya tiene su llave: se le pone SU nombre y entra en el registro.
321
+ * El `id` del perfil ES el nombre de su carpeta, y sale de la llave (`keyDirName`).
322
+ */
323
+ async commit (staging, name, { adopt = false, pub } = {}) {
324
+ if (!pub) throw new Error('cannot name the folder: the key is missing')
325
+ const id = await keyDirName(pub)
326
+ const dest = dirOf(id)
327
+ // Esa llave ya tiene carpeta. No es un choque de nombres: es la MISMA llave dos
328
+ // veces, que es justo lo que este esquema existe para que no pase.
329
+ if (fs.existsSync(dest)) {
330
+ try { fs.rmSync(staging, { recursive: true, force: true }) } catch (_) {}
331
+ throw Object.assign(new Error('that key already has a folder here: ' + id), { code: 'key-exists' })
332
+ }
333
+ fs.renameSync(staging, dest)
239
334
  data.profiles.push({ id, name: cleanName(name), createdAt: Date.now(), ...(adopt ? { adopt: true } : {}) })
240
335
  if (!data.current) data.current = id
241
336
  save()
242
337
  return entry(find(id))
243
338
  },
244
339
 
340
+ /**
341
+ * Crea un perfil entero: hace el sitio, deja que `mintKey` acuñe la llave dentro y
342
+ * mueve la carpeta a su nombre. `mintKey(dir)` devuelve la pública, y es cosa de quien
343
+ * llama porque generar una identidad no es asunto del registro.
344
+ */
345
+ async add (name, { adopt = false, kek = null, mintKey } = {}) {
346
+ if (typeof mintKey !== 'function') throw new Error('add() needs mintKey to know whose folder it is')
347
+ const staging = api.stage({ kek })
348
+ try {
349
+ const pub = await mintKey(staging)
350
+ return await api.commit(staging, name, { adopt, pub })
351
+ } catch (e) {
352
+ // Nada a medias: la carpeta de paso se va con el intento fallido.
353
+ try { fs.rmSync(staging, { recursive: true, force: true }) } catch (_) {}
354
+ throw e
355
+ }
356
+ },
357
+
358
+ /**
359
+ * PONERLE A UNA CARPETA VIEJA EL NOMBRE DE SU LLAVE.
360
+ *
361
+ * Es toda la migración: mover la data a la carpeta que le toca. Se hace con la
362
+ * identidad CERRADA —antes de abrirla—, porque la identidad se queda con la ruta como
363
+ * texto y renombrar por debajo la deja escribiendo en una carpeta que ya no existe.
364
+ *
365
+ * De quién es la carpeta se sabe por la marca (`key.json`); si no la tiene —una
366
+ * instalación anterior a que existiera— se abre un momento solo para preguntárselo.
367
+ */
368
+ async ensureNamedByKey (id, mintKey) {
369
+ const dir = dirOf(id)
370
+ if (!fs.existsSync(dir)) return id
371
+ let pub = keyOwnerOf(dir)
372
+ if (!pub && typeof mintKey === 'function') {
373
+ try { pub = await mintKey(dir) } catch (_) { return id }
374
+ }
375
+ if (!pub) return id
376
+ const quiere = await keyDirName(pub)
377
+ if (quiere === id) return id
378
+ if (fs.existsSync(dirOf(quiere))) {
379
+ throw Object.assign(new Error(`cannot rename ${id}: ${quiere} already exists`), { code: 'key-exists' })
380
+ }
381
+ fs.renameSync(dir, dirOf(quiere))
382
+ const p = find(id)
383
+ if (p) p.id = quiere
384
+ if (data.current === id) data.current = quiere
385
+ if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
386
+ save()
387
+ return quiere
388
+ },
389
+
245
390
  /** Quita la marca de «nació para adoptar» (ya adoptó, o se canceló). */
246
391
  clearAdopt (id) {
247
392
  const p = find(id)
package/src/vault.js CHANGED
@@ -19,15 +19,20 @@ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.j
19
19
  import { createAdminDesk } from '../lib/src/admin.js'
20
20
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
21
21
  import { createTransport, masterPubkeyOf } from './transport.js'
22
+ import { assertKeyOwnsDir } from './keyowner.js'
22
23
  import { openStore } from './store.js'
23
24
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
24
25
  import { openSecretsStore, assertVar } from './secretsStore.js'
25
- import { createApprovals } from './approvals.js'
26
+ // `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
27
+ // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
28
+ // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
29
+ // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
30
+ import { createApprovals, PENDING_TTL_MS } from './approvals.js'
26
31
  import { makeSealer } from './sealer.js'
27
32
  import { openSealKeys } from './sealKey.js'
28
33
  import { seal } from '../lib/src/sealed.js'
29
34
  import { dataDir, ensureDir } from './paths.js'
30
- import { atRestFor, machineKey, migrateFile } from './atrest.js'
35
+ import { atRestFor, kekFor, migrateFile } from './atrest.js'
31
36
  import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
32
37
 
33
38
  /**
@@ -56,10 +61,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
56
61
  // máquina (puede leer el mismo material); es subir el listón, no una imposibilidad.
57
62
  // La migración verifica antes de reemplazar: si algo falla, el original queda intacto.
58
63
  try {
59
- const r = migrateFile(path.join(dir, 'identity.json'), machineKey(dir))
64
+ const r = migrateFile(path.join(dir, 'identity.json'), kekFor(dir))
60
65
  if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
61
66
  } catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
62
67
  const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
68
+ // ESTE DIRECTORIO ES DE ESTA LLAVE. Es lo único que hay que proteger cuando varias
69
+ // bóvedas viven en un mismo disco: cada una con el suyo, para que nunca se mezclen.
70
+ assertKeyOwnsDir(dir, identity.me?.publickey || null)
63
71
  if (!identity.me?.publickey) await identity.setMyNickname('')
64
72
  // CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
65
73
  // igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
@@ -168,7 +176,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
168
176
  recipients: (owner) => recipientsOf(owner),
169
177
  // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
170
178
  signer: (body) => signSeal(body),
171
- defaultKey: () => new Uint8Array(machineKey(dir))
179
+ defaultKey: () => new Uint8Array(kekFor(dir))
172
180
  })
173
181
  // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
174
182
  // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
@@ -629,8 +637,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
629
637
  audit('secrets.pending', { device: deviceId, ns, id: pend.id })
630
638
  log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
631
639
  const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
632
- const { signature } = await identity.signData(body)
633
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
640
+ // El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
641
+ // esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
642
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record || null })
634
643
  await notifyApprovers(pend, record)
635
644
  return
636
645
  }
@@ -654,13 +663,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
654
663
  // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
655
664
  // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
656
665
  // de dónde salió lo que abre.
666
+ // El acta va en LOS DOS caminos, también en el v3 heredado: ya no sirve solo para
667
+ // decir de dónde salieron los sobres, sino para saber qué llave podía firmar esta
668
+ // respuesta. Sin ella el agente no puede comprobar quién le contestó.
657
669
  const payload = b.legacy
658
- ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
670
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])), acta: record || null }
659
671
  : { sealed: b, acta: record || null }
660
672
  const enc = await seal({ ek, payload })
661
673
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
662
- const { signature } = await identity.signData(body)
663
- return { body, signature }
674
+ // LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
675
+ // no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
676
+ // misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
677
+ // bloqueado, y algún día desde una réplica que no tiene la maestra ni debe tenerla.
678
+ return { body, seal: await sealOrFail(body) }
679
+ }
680
+
681
+ /**
682
+ * Firma con la llave de sellado, o revienta. No hay repliegue a la maestra: si el acta
683
+ * no nombra una llave de sellado que sea nuestra, esta bóveda no está en condiciones de
684
+ * servir, y decirlo es mejor que servir firmado por quien no toca.
685
+ */
686
+ async function sealOrFail (body) {
687
+ const s = await signSeal(body)
688
+ if (!s) throw new Error('this vault has no sealing key named by the record: it cannot serve')
689
+ return s
664
690
  }
665
691
 
666
692
  /**
@@ -684,8 +710,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
684
710
  const by = await deviceIdOf(chk.device).catch(() => null)
685
711
  const answer = async (body) => {
686
712
  body = { ...body, ts: Date.now() }
687
- const { signature } = await identity.signData(body)
688
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
713
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
689
714
  }
690
715
  if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
691
716
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
@@ -748,11 +773,34 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
748
773
  if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
749
774
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
750
775
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
776
+ if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
751
777
  } catch (e) {
752
778
  reply(from, { type: MSG.ERROR, error: e.message })
753
779
  }
754
780
  })
755
781
 
782
+ /**
783
+ * AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
784
+ * ADOPTA, que es lo que hace que conceder un permiso surta efecto en la otra máquina en
785
+ * vez de esperar a que renueve su cert.
786
+ *
787
+ * No se comprueba quién lo manda y no hace falta: `adoptActa` aplica §2.4.1 —firma,
788
+ * encadenado, `seq` que no baja y desempate— así que un acta ajena o vieja se rechaza
789
+ * sola. Fiarse del remitente sería la comprobación débil; fiarse del acta es la fuerte.
790
+ */
791
+ async function handleAdminEvent (payload) {
792
+ const acta = payload?.acta
793
+ if (!acta || typeof acta !== 'object') return
794
+ try {
795
+ const r = await identity.adoptActa?.(acta)
796
+ if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)
797
+ // Y el RECHAZO también se dice. Callarlo dejaría el peor fallo de todos: se concede
798
+ // un permiso en una máquina, la otra no se entera, y no hay una sola línea que lo
799
+ // explique — que es exactamente lo que costó encontrar esto.
800
+ else log(`[vault] IGNORED the record #${acta.seq} announced by another vault: ${r?.reason || 'no reason given'}`)
801
+ } catch (e) { log('[vault] could not adopt the announced record:', e.message) }
802
+ }
803
+
756
804
  // ----- LA BÓVEDA DE CONTRASEÑAS -----
757
805
  //
758
806
  // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
@@ -961,17 +1009,45 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
961
1009
  try {
962
1010
  const body = { ev, ...info, ts: Date.now() }
963
1011
  const { signature } = await identity.signData(body)
1012
+ // EL ACTA VIAJA CON EL AVISO. Sin esto, un miembro se enteraba de que «algo cambió»
1013
+ // pero no de QUÉ, y no veía el acta nueva hasta renovar su cert — hasta 30 días.
1014
+ // Para un aparato eso era lento; para OTRA BÓVEDA es fatal: se le concede `sella` y
1015
+ // no puede sellar, porque su copia del acta no lo dice todavía.
1016
+ // No es un dato secreto (es pública dentro del perfil y estos son sus miembros), y
1017
+ // quien la recibe la adopta por las reglas de §2.4.1, así que una vieja o ajena no
1018
+ // hace daño: se rechaza sola.
1019
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
964
1020
  const { issued } = await identity.listDelegations()
1021
+ // A QUIÉN SE AVISA: a los MIEMBROS DEL ACTA, no solo a quien esta bóveda enroló.
1022
+ //
1023
+ // Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
1024
+ // eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
1025
+ // `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
1026
+ // acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
1027
+ // tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
1028
+ // quedaban con actas distintas y la del dueño sin enterarse de nada.
1029
+ //
1030
+ // El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
1031
+ // suma porque cubre a quien tiene cert y todavía no aparece ahí.
1032
+ const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
1033
+ const yo = identity.me?.publickey || null
965
1034
  // UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
966
1035
  // MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
967
1036
  // y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
968
1037
  // cuidado que en `notifyNsChange`.
969
1038
  const seen = new Set()
970
- for (const d of issued || []) {
971
- if (!d.sub || seen.has(d.sub)) continue
972
- seen.add(d.sub)
973
- try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
1039
+ const avisar = (pub) => {
1040
+ if (!pub || pub === yo || seen.has(pub)) return
1041
+ seen.add(pub)
1042
+ // El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
1043
+ // bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
1044
+ // de log cuesta días (la última vez, tres).
1045
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }) }
1046
+ catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
974
1047
  }
1048
+ for (const m of miembros) avisar(m.pub)
1049
+ for (const d of issued || []) avisar(d.sub)
1050
+ log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
975
1051
  } catch (e) { log('[vault] could not notify members of the change:', e.message) }
976
1052
  }
977
1053
 
@@ -1150,7 +1226,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1150
1226
  * pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
1151
1227
  * diseño). Prometer una protección que no está puesta es peor que no tenerla.
1152
1228
  */
1153
- const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
1229
+ const adminKeyOr = (adminKey) => adminKey || new Uint8Array(kekFor(dir))
1154
1230
 
1155
1231
  // `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
1156
1232
  // usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
@@ -1231,18 +1307,48 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1231
1307
  const rewrapWaiters = new Set()
1232
1308
  const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
1233
1309
 
1310
+ /**
1311
+ * LAS OTRAS BÓVEDAS —los miembros con el permiso `sella`— entran en TODOS los cajones,
1312
+ * también en los que tienen dueño. Decidido por el dueño el 2026-08-30.
1313
+ *
1314
+ * Y no contradice la regla de agosto —«un cajón con dueño no se envuelve para quien
1315
+ * administra»—, porque un cosellador **no es quien administra**: es un master. Aquella
1316
+ * regla saca a los aparatos de consola (un navegador, un portátil con la sesión puesta),
1317
+ * cuyo compromiso es probable y cuya necesidad es comodidad. Una segunda bóveda existe
1318
+ * justamente para poder REGENERAR los sobres el día que la primera no esté, y regenerar
1319
+ * exige abrir: dejarla fuera la haría inútil para el único desastre que viene a cubrir.
1320
+ *
1321
+ * El precio, que se dice y no se esconde: a partir de aquí son DOS los discos cuya
1322
+ * captura abre ese cajón. Eso es exactamente lo que cuesta sobrevivir a perder uno.
1323
+ */
1324
+ async function cosealerMembers () {
1325
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1326
+ if (!record) return []
1327
+ // Sin `encPub` no hay a dónde envolver: se queda en deuda y se ve como `sinLlave`,
1328
+ // igual que cualquier otro miembro incompleto.
1329
+ return (record.members || []).filter((m) =>
1330
+ m.pub !== master && m.encPub && Acta.memberCan(record, m.pub, 'sealer'))
1331
+ }
1332
+
1333
+ /** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
1334
+ const conCoselladores = (base, co) => {
1335
+ const vistos = new Set(base.map((m) => m.pub))
1336
+ return [...base, ...co.filter((m) => !vistos.has(m.pub))]
1337
+ }
1338
+
1234
1339
  async function recipientsOf (owner) {
1340
+ const co = await cosealerMembers()
1235
1341
  if (owner.startsWith('ns:')) {
1236
1342
  const owned = await nsMembers(owner.slice(3))
1237
1343
  // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
1238
1344
  // administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
1239
- return owned.length ? owned : await adminDevices()
1345
+ return conCoselladores(owned.length ? owned : await adminDevices(), co)
1240
1346
  }
1241
1347
  const pub = owner.slice(owner.indexOf(':') + 1)
1242
1348
  const m = await memberOf(pub)
1243
1349
  // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
1244
- if (m?.cn) return [m]
1245
- return [...(m ? [m] : []), ...await adminDevices()]
1350
+ if (m?.cn) return conCoselladores([m], co)
1351
+ return conCoselladores([...(m ? [m] : []), ...await adminDevices()], co)
1246
1352
  }
1247
1353
 
1248
1354
  /**
@@ -1818,6 +1924,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1818
1924
  isMaster: () => identity.isMaster(),
1819
1925
  setCaps: async (pub, caps) => {
1820
1926
  const r = await identity.setCaps(pub, caps)
1927
+ // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
1928
+ // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
1929
+ // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
1930
+ // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
1931
+ // contenedor, porque la imagen traía la versión de npm y el árbol local otra.
1932
+ try {
1933
+ const quedaron = new Set((await identity.profileMembers()).members.find((m) => m.pub === pub)?.caps || [])
1934
+ const perdidos = caps.filter((c) => !quedaron.has(c))
1935
+ if (perdidos.length) {
1936
+ log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
1937
+ }
1938
+ } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
1821
1939
  audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
1822
1940
  await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
1823
1941
  return r