@dotrino/vaultd 0.108.0 → 0.111.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/lib/src/admin.js CHANGED
@@ -61,7 +61,11 @@ export const ADMIN_OPS = Object.freeze([
61
61
  // documentada en `buildSealedVar` y la consola la llamaba, pero no existía — así que
62
62
  // crear una variable desde la consola contestaba «admin: invalid operation». No expone
63
63
  // nada nuevo: son llaves PÚBLICAS, las mismas que ya viajan en el acta.
64
- 'vars', 'var.set', 'var.setMany', 'var.recipients'
64
+ 'vars', 'var.set', 'var.setMany', 'var.recipients',
65
+ // ABRIR LA BÓVEDA A DISTANCIA (`docs/abrir-a-distancia.md`). Dos pasos: `unlock.begin`
66
+ // pide una llave efímera y los parámetros del molino; `unlock` trae la derivada dentro
67
+ // de un sobre que solo esta bóveda puede abrir. No hay una tercera.
68
+ 'unlock.begin', 'unlock'
65
69
  ])
66
70
 
67
71
  /**
@@ -90,7 +94,13 @@ export const ADMIN_OPS = Object.freeze([
90
94
  * Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
91
95
  * enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
92
96
  */
93
- export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars', 'var.set', 'var.setMany', 'var.recipients'])
97
+ export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
98
+ 'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
99
+ // Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
100
+ // echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
101
+ // y frescura como todas, y además la contraseña dentro del sobre.
102
+ 'unlock.begin', 'unlock'
103
+ ])
94
104
 
95
105
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
96
106
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
@@ -133,6 +143,10 @@ export function authorBody (owner, key, e, ts) {
133
143
  export function createAdminDesk ({
134
144
  desk, verify, readActivity = () => [], deviceIdOf, vars = null,
135
145
  notify = async () => {}, audit = () => {}, isLocked = () => false,
146
+ // ABRIR A DISTANCIA. Va inyectado —como `vars`— porque abrir necesita cripto y disco, y
147
+ // este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
148
+ // operación existe y contesta que no, en vez de romper con un TypeError.
149
+ unlockDesk = null,
136
150
  now = () => Date.now()
137
151
  }) {
138
152
  const ops = new Set(ADMIN_OPS)
@@ -193,6 +207,17 @@ export function createAdminDesk ({
193
207
 
194
208
  try {
195
209
 
210
+ // ABRIR A DISTANCIA. La bóveda hace el trabajo; aquí solo se enruta, porque este
211
+ // módulo es puro —sin cripto, sin disco— y abrir necesita las dos cosas.
212
+ if (data.op === 'unlock.begin') {
213
+ if (!unlockDesk) return { ok: false, error: 'admin: this vault cannot be opened remotely (update it)' }
214
+ return { ok: true, result: await unlockDesk.begin({ by, device: chk.device }) }
215
+ }
216
+ if (data.op === 'unlock') {
217
+ if (!unlockDesk) return { ok: false, error: 'admin: this vault cannot be opened remotely (update it)' }
218
+ return await unlockDesk.open({ nonce: data.nonce, enc: data.enc, by, device: chk.device })
219
+ }
220
+
196
221
  if (data.op === 'audit') {
197
222
  const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
198
223
  return { ok: true, result: { entries: readActivity(limit) } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.108.0",
3
+ "version": "0.111.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "0.79.0",
25
+ "@dotrino/identity": "0.81.0",
26
26
  "@dotrino/passmanager": "^0.10.1",
27
27
  "@dotrino/proxy-client": "0.15.0",
28
28
  "@dotrino/roadmap": "0.2.0",
package/src/ctl.js CHANGED
@@ -598,6 +598,11 @@ async function cmdMembers () {
598
598
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
599
599
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
600
600
  // fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
601
+ // RENUNCIAS SIN SELLAR. Ya no los tiene —la bóveda no se los reconoce— pero el acta
602
+ // que se lee arriba todavía los nombra, así que hay que decirlo aquí o la lista miente.
603
+ if (m.renounced?.length) {
604
+ console.log(' %s↓ renunció a %s (pendiente de sellar: abre la bóveda)%s', R, m.renounced.join(', '), Z)
605
+ }
601
606
  if (m.running) {
602
607
  const v = `${m.running.product} ${m.running.version}`
603
608
  if (m.running.compatible) console.log(' %scorre %s%s', D, v, Z)
@@ -1577,8 +1582,28 @@ async function cmdProfile (rest) {
1577
1582
  reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
1578
1583
  return
1579
1584
  }
1585
+ // LA CONTRASEÑA DEL ADMIN. Otra, distinta, que solo sirve desde el admin remoto — aquí
1586
+ // no abre nada. Pantalla administrativa: dice qué hace y qué no, y ya (§5.1).
1587
+ case 'admin-password': {
1588
+ const action = args[0]
1589
+ if (action === 'rm') {
1590
+ reportProfiles(await profileRequest('admin-password-rm', {}))
1591
+ return
1592
+ }
1593
+ if (action && action !== 'set') { console.error('uso: dotrino-vault profile admin-password [set|rm]'); process.exit(2) }
1594
+ console.log('Una contraseña APARTE, para abrir la bóveda desde el admin remoto.')
1595
+ console.log('Aquí no abre nada: tecleada en esta máquina no vale. Y para usarla hace')
1596
+ console.log('falta además un aparato tuyo que administre — la contraseña sola no basta.')
1597
+ console.log('\nEl perfil tiene que estar abierto ahora mismo para poder ponerla.')
1598
+ const pwd = await askPassword('\nContraseña del admin: ')
1599
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
1600
+ const again = await askPassword('Repítela: ')
1601
+ if (pwd !== again) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
1602
+ reportProfiles(await profileRequest('admin-password-set', { password: pwd }))
1603
+ return
1604
+ }
1580
1605
  default:
1581
- console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password}'); process.exit(2)
1606
+ console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password|admin-password}'); process.exit(2)
1582
1607
  }
1583
1608
  }
1584
1609
 
package/src/daemon.js CHANGED
@@ -24,6 +24,7 @@ import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
24
24
  import { parseInvite } from '../lib/src/invite.js'
25
25
  import { watchBinary } from './selfupdate.js'
26
26
  import { VERSION } from './version.js'
27
+ import crypto from 'node:crypto'
27
28
 
28
29
  const readJsonSafe = (f) => ipcRead(f, null)
29
30
  const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
@@ -400,6 +401,35 @@ export async function runDaemon () {
400
401
  mgr.profiles.removePassword(id)
401
402
  return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
402
403
  }
404
+ // ----- LA CONTRASEÑA DEL ADMIN (`docs/abrir-a-distancia.md`) -----
405
+ //
406
+ // Otra contraseña, distinta, que **solo sirve por el camino del admin**. Lo que se
407
+ // guarda es una copia de la llave del perfil cifrada con lo que sale de ella; por eso
408
+ // ponerla exige el perfil abierto: esa llave solo está en memoria con el candado
409
+ // descorrido.
410
+ //
411
+ // El molino se hace AQUÍ porque quien la pone está delante de la máquina y ya tecleó
412
+ // la principal. Cuando se USA a distancia lo hace el navegador, y por eso la bóveda
413
+ // publica los parámetros (`secondaryParams`).
414
+ case 'admin-password-set': {
415
+ const id = ref()
416
+ if (!req.password) throw new Error('an admin password is required')
417
+ // El salt se acuña ANTES de derivar (`mint`), para derivar una sola vez con el
418
+ // definitivo. El molino se hace aquí porque quien la pone está delante de la
419
+ // máquina y acaba de teclear la principal; cuando se USA a distancia lo hace el
420
+ // navegador, y por eso la bóveda publica estos mismos parámetros.
421
+ const params = mgr.profiles.secondaryParams(id, { mint: true })
422
+ const derivada = new Uint8Array(crypto.scryptSync(
423
+ String(req.password), Buffer.from(params.salt, 'base64'),
424
+ params.len, { N: params.N, r: params.r, p: params.p }))
425
+ try { mgr.profiles.setSecondary(id, derivada) } finally { wipe(derivada) }
426
+ return { done: 'contraseña del admin guardada · solo sirve desde el admin, no aquí' }
427
+ }
428
+ case 'admin-password-rm': {
429
+ const id = ref()
430
+ const r = mgr.profiles.clearSecondary(id)
431
+ return { done: r.had ? 'contraseña del admin quitada' : 'no había ninguna' }
432
+ }
403
433
  default: throw new Error('unknown profile operation: ' + req.op)
404
434
  }
405
435
  }
package/src/manager.js CHANGED
@@ -98,6 +98,22 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
98
98
  // Para la consola remota: la contraseña llega dentro del sobre firmado y hay que
99
99
  // convertirla en la llave que abre la copia maestra de los secretos.
100
100
  deriveAdminKey: (password) => profiles.adminKey(id, password),
101
+ // ABRIR A DISTANCIA (`docs/abrir-a-distancia.md`). La bóveda no toca el registro de
102
+ // perfiles: se le pasan las tres cosas que necesita y nada más — con qué números
103
+ // deriva el admin, cómo se abre con la derivada, y si hay contraseña de admin puesta.
104
+ secondaryParams: () => profiles.secondaryParams(id),
105
+ openWithSecondary: async (derivada) => {
106
+ const r = await profiles.openWithSecondary(id, derivada)
107
+ // Igual que `unlock`: abrir el perfil no basta, hay que traer la maestra a memoria.
108
+ // Sin esto el candado diría «abierto» y todo lo que la necesita fallaría en otro
109
+ // sitio y por otro motivo, que es el fallo que ya se pagó una vez.
110
+ try { await running.get(id)?.takeMasterKey?.() } catch (e) {
111
+ try { profiles.lock(id) } catch (_) {}
112
+ throw new Error(`opened the profile but could not open it: ${e.message}`)
113
+ }
114
+ return r
115
+ },
116
+ hasSecondary: () => profiles.hasSecondary(id),
101
117
  // La llave del perfil MIENTRAS esté abierto, o null. Con ella, enrolar un servicio
102
118
  // con la bóveda abierta le envuelve su cajón en el acto en vez de dejarlo entrar
103
119
  // sin poder leer nada (ver `profiles.js`, `llaves`).
package/src/profiles.js CHANGED
@@ -24,12 +24,22 @@ 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, kekFor } from './atrest.js'
27
+ import { atRestFor, migrateFile, kekFor, encryptText, decryptText } from './atrest.js'
28
28
  import { probe as probeKek, writeConfig as writeKekConfig, configFromEnv } from './atrest.js'
29
29
  import { keyDirName, keyOwnerOf } from './keyowner.js'
30
30
 
31
31
  const REGISTRY = 'profiles.json'
32
32
  const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
33
+
34
+ /**
35
+ * EL MOLINO, en un solo sitio. Estaba escrito a mano en cada llamada, y con la contraseña
36
+ * del admin eso deja de ser cosmética: **el navegador tiene que derivar con exactamente
37
+ * estos números**. Si aquí se cambia uno y allá no, la contraseña «deja de funcionar» sin
38
+ * que nada diga por qué, que es la clase de fallo que este proyecto viene evitando.
39
+ *
40
+ * Por eso también se le mandan al admin (`secondaryParams`) en vez de que los copie.
41
+ */
42
+ export const SCRYPT = Object.freeze({ N: 16384, r: 8, p: 1, len: 32 })
33
43
  const MAX_NAME = 40
34
44
  /**
35
45
  * Lo MÍNIMO que se acepta al poner una contraseña.
@@ -98,7 +108,7 @@ const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
98
108
  */
99
109
  function deriveScryptPwd (password, saltB64) {
100
110
  const salt = Buffer.from(saltB64, 'base64')
101
- return b64(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
111
+ return b64(crypto2.scryptSync(String(password || ''), salt, SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p }))
102
112
  }
103
113
 
104
114
  /** PBKDF2-SHA256 → verificador base64 (v1, heredado). */
@@ -228,6 +238,35 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
228
238
  return p
229
239
  }
230
240
 
241
+ /**
242
+ * EL FRENO DE FUERZA BRUTA, en un solo sitio porque ahora hay DOS puertas.
243
+ *
244
+ * Tras 5 fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro. Los
245
+ * fallos VIEJOS se olvidan (`TRIES_FORGET_MS`): sigue frenando una ráfaga, pero no
246
+ * convierte un despiste de ayer en un vault que ya no se abre.
247
+ *
248
+ * **Las dos contraseñas cuentan en el MISMO contador**, y no es un detalle: si cada una
249
+ * llevara el suyo, probar por un camino no frenaría el otro y el freno valdría la mitad.
250
+ *
251
+ * Lanza con CÓDIGO: la TUI es bilingüe y lo traduce, y la CLI lo dice con sus palabras.
252
+ * Sin código, el rechazo llegaba como un texto suelto del daemon, indistinguible de
253
+ * «se volvió a pedir la contraseña porque sí».
254
+ */
255
+ function frenar (p) {
256
+ let tries = p.tries || { n: 0, at: 0 }
257
+ if (tries.at && Date.now() - tries.at > TRIES_FORGET_MS) {
258
+ tries = { n: 0, at: 0 }
259
+ if (p.tries) { delete p.tries; save() }
260
+ }
261
+ const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
262
+ const left = tries.at + waitMs - Date.now()
263
+ if (left > 0) {
264
+ throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
265
+ { code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
266
+ }
267
+ return tries
268
+ }
269
+
231
270
  const api = {
232
271
  get root () { return root },
233
272
  dirOf,
@@ -497,32 +536,13 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
497
536
  save()
498
537
  }
499
538
  const salt = Buffer.from(p.kdf.salt, 'base64')
500
- return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
539
+ return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, SCRYPT.len, { N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p }))
501
540
  },
502
541
 
503
542
  async unlock (id, password) {
504
543
  const p = assertExists(id)
505
544
  if (!p.pwd) { open(id); return { ok: true, locked: false } }
506
- // Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
507
- // fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
508
- // Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
509
- // el rato, se empieza de cero. Así el freno sigue frenando una ráfaga —los intentos
510
- // seguidos se cuentan igual— pero no convierte un despiste de ayer en un vault que
511
- // ya no se abre.
512
- let tries = p.tries || { n: 0, at: 0 }
513
- if (tries.at && Date.now() - tries.at > TRIES_FORGET_MS) {
514
- tries = { n: 0, at: 0 }
515
- if (p.tries) { delete p.tries; save() }
516
- }
517
- const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
518
- const left = tries.at + waitMs - Date.now()
519
- // Con CÓDIGO: la TUI es bilingüe y lo traduce, y la CLI puede decirlo con sus
520
- // palabras. Sin código, el rechazo llegaba como un texto suelto del daemon y era
521
- // indistinguible de «se volvió a pedir la contraseña porque sí».
522
- if (left > 0) {
523
- throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
524
- { code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
525
- }
545
+ const tries = frenar(p)
526
546
  const proof = p.pwd.v === 2
527
547
  ? deriveScryptPwd(password, p.pwd.salt)
528
548
  : await derivePwd(password, p.pwd.salt, p.pwd.iter)
@@ -547,6 +567,99 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
547
567
  return { ok: true, locked: false }
548
568
  },
549
569
 
570
+ // ----- LA SEGUNDA CONTRASEÑA: la del admin (`docs/abrir-a-distancia.md`) -----
571
+ //
572
+ // DOS PUERTAS AL MISMO SITIO. La llave del perfil sale de `scrypt(principal, p.kdf.salt)`
573
+ // y es lo que destapa la maestra. Una segunda contraseña NO puede derivar una llave
574
+ // distinta —tiene que llegar a la misma—, así que lo que se guarda es **una copia de esa
575
+ // llave, cifrada con lo que sale de la secundaria**. Es el mismo patrón que los cajones:
576
+ // varios sobres, un destinatario cada uno.
577
+ //
578
+ // EL MOLINO LO HACE QUIEN LLAMA, no esto. Aquí entra `derivada` ya calculada, y hay dos
579
+ // motivos: la bóveda nunca llega a ver la contraseña del admin, y adivinar le cuesta el
580
+ // scrypt AL QUE PRUEBA en vez de costarle CPU a la bóveda (que además sería una forma de
581
+ // ahogarla). El freno sigue contando los fallos igual — ver `frenar`.
582
+ //
583
+ // Y por eso `derivada` VALE TANTO COMO LA CONTRASEÑA: quien la capture abre igual. Viaja
584
+ // cifrada a una llave efímera de la bóveda y la petición va firmada por un aparato del
585
+ // acta; esas dos cosas son las que la protegen, no el hecho de estar derivada.
586
+
587
+ /**
588
+ * Los parámetros para que el admin derive EXACTAMENTE igual. Son públicos: un salt no es
589
+ * un secreto y ya vive en el disco junto a los datos. `null` si este perfil no tiene
590
+ * segunda contraseña — entonces abrir a distancia sencillamente no está disponible.
591
+ */
592
+ secondaryParams (id, { mint = false } = {}) {
593
+ const p = find(id)
594
+ if (!p) return null
595
+ // `mint`: acuña el salt si todavía no hay ninguno. Lo pide QUIEN LA PONE, para poder
596
+ // derivar una sola vez con el salt definitivo — si no, habría que derivar, guardar, y
597
+ // volver a derivar con el salt que salió, que es como lo escribí primero y era torpe.
598
+ if (!p.kdf2) {
599
+ if (!mint) return null
600
+ p.kdf2 = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))), wrapped: null }
601
+ save()
602
+ }
603
+ return { salt: p.kdf2.salt, ...SCRYPT }
604
+ },
605
+
606
+ /** ¿Tiene puesta la del admin? Es estado, y se enseña donde se administra. */
607
+ hasSecondary: (id) => !!find(id)?.kdf2?.wrapped,
608
+
609
+ /**
610
+ * Pone o cambia la contraseña del admin. **Exige el perfil ABIERTO**, porque lo que se
611
+ * guarda es la llave del perfil y esa solo está en memoria con el candado descorrido.
612
+ * Es lo correcto además de lo posible: ponerla es una decisión del dueño, y el dueño
613
+ * acaba de teclear la principal.
614
+ */
615
+ setSecondary (id, derivada) {
616
+ const p = assertExists(id)
617
+ if (!p.pwd) throw Object.assign(new Error('this profile has no password: there is nothing to open remotely'), { code: 'NO_PASSWORD' })
618
+ const llave = api.openKey(id)
619
+ if (!llave) throw Object.assign(new Error('open the profile first (dotrino-vault unlock)'), { code: 'PROFILE_LOCKED' })
620
+ if (!(derivada instanceof Uint8Array) || derivada.length !== 32) throw new Error('the admin key must be 32 bytes')
621
+ const salt = p.kdf2?.salt || b64(crypto.getRandomValues(new Uint8Array(32)))
622
+ p.kdf2 = { v: 1, salt, wrapped: encryptText(b64(llave), Buffer.from(derivada)) }
623
+ save()
624
+ return { ok: true }
625
+ },
626
+
627
+ /** La quita. Revocar es borrar el sobre: al instante y sin tocar la principal. */
628
+ clearSecondary (id) {
629
+ const p = assertExists(id)
630
+ if (!p.kdf2?.wrapped) { if (p.kdf2) { delete p.kdf2; save() } ; return { ok: true, had: false } }
631
+ delete p.kdf2
632
+ save()
633
+ return { ok: true, had: true }
634
+ },
635
+
636
+ /**
637
+ * ABRE CON LA DEL ADMIN. Recibe la derivada, descifra el sobre y saca la llave del
638
+ * perfil: de ahí para abajo el estado es idéntico a `unlock`, porque es la misma llave.
639
+ *
640
+ * No hay verificador aparte y no hace falta: AES-GCM autentica, así que una derivada
641
+ * equivocada **no descifra**, y eso ES la comprobación.
642
+ *
643
+ * Comparte el contador de `unlock` (`frenar`): probar por aquí frena allá y al revés.
644
+ */
645
+ async openWithSecondary (id, derivada) {
646
+ const p = assertExists(id)
647
+ if (!p.kdf2?.wrapped) throw Object.assign(new Error('this profile has no admin password'), { code: 'NO_SECONDARY' })
648
+ const tries = frenar(p)
649
+ let llave = null
650
+ try { llave = Buffer.from(decryptText(p.kdf2.wrapped, Buffer.from(derivada)), 'base64') } catch (_) { llave = null }
651
+ if (!llave || llave.length !== 32) {
652
+ p.tries = { n: tries.n + 1, at: Date.now() }
653
+ save()
654
+ throw Object.assign(new Error('wrong admin password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
655
+ }
656
+ delete p.tries
657
+ save()
658
+ open(id)
659
+ llaves.set(id, new Uint8Array(llave))
660
+ return { ok: true, locked: false }
661
+ },
662
+
550
663
  lock (id) { assertExists(id); unlocked.delete(id); olvidar(id); return { ok: true, locked: api.isLocked(id) } },
551
664
 
552
665
  /**
package/src/subacta.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * subacta.js — LO DECIDIDO QUE TODAVÍA NO SELLÓ EL ACTA.
3
+ *
4
+ * Idea del dueño (2026-09-04): *«una subacta efímera que se comparte entre todos los
5
+ * dispositivos y puede tener decisiones a futuro que se aceptan cuando el acta se abre»*.
6
+ *
7
+ * EL AGUJERO QUE TAPA. Sellar el acta es de la maestra, y con el perfil cerrado la maestra
8
+ * no está. Así que una renuncia —«me quito estos permisos»— no surtía NINGÚN efecto hasta
9
+ * que alguien iba a la máquina y tecleaba la contraseña. Justo el caso que la renuncia
10
+ * existe para cubrir: te roban el teléfono, renuncias desde él, y el ladrón sigue siendo
11
+ * admin hasta que llegues. El pilar ya tenía la pieza (`extraRenounces`) y no la pasaba
12
+ * nadie.
13
+ *
14
+ * LA REGLA DURA, y es la que sostiene todo lo demás:
15
+ *
16
+ * LA SUBACTA SOLO PUEDE QUITAR, NUNCA DAR.
17
+ *
18
+ * De ahí salen las tres propiedades que la hacen segura sin maestra:
19
+ *
20
+ * · **Se autentica sola.** Cada entrada la firma EL MIEMBRO AL QUE AFECTA. No hace falta
21
+ * sellador, así que no hay una segunda autoridad al lado del acta.
22
+ * · **Una entrada falsa o repetida cuesta disponibilidad, no autoridad.** Lo peor que
23
+ * consigue quien cuele una es quitarle permisos a alguien: molesto y reversible.
24
+ * · **Nadie puede quitarle nada a otro.** Si valiera la firma de cualquiera, un servicio
25
+ * con `secrets` en un VPS alquilado podría dejar sin `admin` a tu teléfono.
26
+ *
27
+ * Y por eso el catálogo de operaciones es una LISTA BLANCA de una sola: `renounce`. La
28
+ * lista es la regla — si algún día entra otra, tiene que quitar y venir firmada por su
29
+ * sujeto, y se escribe aquí a propósito, no se cuela.
30
+ *
31
+ * JUNTAR DOS ES UNIR. Dos entradas nunca se contradicen porque las dos quitan, así que no
32
+ * hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
33
+ * por reloj, o un aparato apagado perdería justo la que lo protege.
34
+ */
35
+ import path from 'node:path'
36
+ import { verifyRenounce } from '@dotrino/identity/acta'
37
+ import { readJson, writeJson } from './paths.js'
38
+
39
+ /** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
40
+ export const OPS = Object.freeze(['renounce'])
41
+
42
+ /** Identidad de una entrada, para no guardar dos veces la misma. */
43
+ const idOf = (e) => `${e.op}|${e.member}|${[...(e.caps || [])].sort().join(',')}|${e.ts}`
44
+
45
+ /**
46
+ * ¿Es una entrada que se puede aceptar? Comprueba la FORMA y la FIRMA; que quite es
47
+ * estructural (el catálogo) y no una comprobación aparte.
48
+ */
49
+ export async function isAcceptable (e) {
50
+ if (!e || !OPS.includes(e.op)) return false
51
+ if (e.op === 'renounce') return verifyRenounce(e).catch(() => false)
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * La subacta de un perfil. Vive en su directorio y va cifrada en reposo como todo lo
57
+ * demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
58
+ * cuenta aunque no sea un secreto.
59
+ */
60
+ export function openSubacta (dir, atRest) {
61
+ const file = path.join(dir, 'subacta.json')
62
+ let data = readJson(file, null, atRest) || { v: 1, entries: [] }
63
+ const guardar = () => writeJson(file, data, atRest)
64
+
65
+ return {
66
+ /** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
67
+ entries () { return [...data.entries] },
68
+ get count () { return data.entries.length },
69
+
70
+ /** Las de UN miembro. Es lo que se mira al decidir sobre él. */
71
+ forMember (pub) { return data.entries.filter((e) => e.member === pub) },
72
+
73
+ /**
74
+ * Acepta una entrada. Devuelve `{ok, reason}` — y un `false` aquí no es un fallo del
75
+ * que la manda: puede ser que ya la tuviéramos, que es el caso normal cuando dos
76
+ * aparatos se cuentan lo mismo.
77
+ */
78
+ async add (e) {
79
+ if (!(await isAcceptable(e))) return { ok: false, reason: 'no-aceptable' }
80
+ const id = idOf(e)
81
+ if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'ya-estaba' }
82
+ data.entries = [...data.entries, e]
83
+ guardar()
84
+ return { ok: true, reason: 'nueva' }
85
+ },
86
+
87
+ /**
88
+ * SACA TODO para absorberlo en el acta, y vacía. Se llama con la bóveda ABIERTA, que es
89
+ * la única que puede sellar. Vaciar aquí y fallar el sellado perdería la entrada, así
90
+ * que quien llama tiene que absorber primero y vaciar después (`clear`).
91
+ */
92
+ drain () { return [...data.entries] },
93
+
94
+ /** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
95
+ * puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
96
+ clear (absorbidas) {
97
+ const ids = new Set(absorbidas.map(idOf))
98
+ data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
99
+ guardar()
100
+ }
101
+ }
102
+ }
package/src/vault.js CHANGED
@@ -15,7 +15,7 @@ import nodeCrypto from 'node:crypto'
15
15
  import path from 'node:path'
16
16
  import { Identity } from '@dotrino/identity/node'
17
17
  import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
18
- import * as Acta from '@dotrino/identity/acta'
18
+ import * as ActaPilar from '@dotrino/identity/acta'
19
19
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
20
20
  import { createAdminDesk, authorBody } from '../lib/src/admin.js'
21
21
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
@@ -27,6 +27,8 @@ import { assertKeyOwnsDir } from './keyowner.js'
27
27
  import { openStore } from './store.js'
28
28
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
29
29
  import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
30
+ import { openSubacta } from './subacta.js'
31
+ import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
30
32
  import { VERSION } from './version.js'
31
33
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
32
34
  import { loadManifest, brokenOf } from '@dotrino/roadmap'
@@ -62,7 +64,7 @@ const MAX_REPLY_BYTES = 768 * 1024
62
64
  * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
63
65
  * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
64
66
  */
65
- export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, openKey = null, forAdoption = false, onAdopted } = {}) {
67
+ 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 } = {}) {
66
68
  ensureDir(dir)
67
69
  // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
68
70
  // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
@@ -372,6 +374,55 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
372
374
  /** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
373
375
  const versionDe = (pub) => versiones.get(pub) || null
374
376
 
377
+ // ----- LA SUBACTA: lo decidido que todavía no selló el acta (`src/subacta.js`) -----
378
+ //
379
+ // Sellar es de la maestra, así que con el perfil CERRADO una renuncia no surtía ningún
380
+ // efecto: el aparato conservaba todos sus permisos hasta que alguien fuera a la máquina
381
+ // a teclear la contraseña. Justo el caso que la renuncia existe para cubrir.
382
+ //
383
+ // Se honra ya, sin sellar, y es seguro porque una entrada de la subacta SOLO PUEDE
384
+ // QUITAR: no concede nada a nadie, y va firmada por el miembro al que afecta.
385
+ const subacta = openSubacta(dir, atRestFor(dir))
386
+
387
+ /**
388
+ * EL ACTA, LEÍDA CON LA SUBACTA EN LA MANO.
389
+ *
390
+ * Envuelve al pilar en UN sitio en vez de pasar la lista en los diecisiete mostradores:
391
+ * una comprobación repetida diecisiete veces son dieciséis sitios donde olvidarla, y
392
+ * olvidarla aquí significa atender a un aparato que ya renunció.
393
+ *
394
+ * Solo se tocan las cinco que DECIDEN. El resto del pilar pasa tal cual.
395
+ */
396
+ const Acta = {
397
+ ...ActaPilar,
398
+ memberCan: (acta, pub, cap) => ActaPilar.memberCan(acta, pub, cap, subacta.forMember(pub)),
399
+ memberCanScope: (acta, pub, scope) => ActaPilar.memberCanScope(acta, pub, scope, subacta.forMember(pub)),
400
+ memberCanReadSecrets: (acta, pub, ns) => ActaPilar.memberCanReadSecrets(acta, pub, ns, subacta.forMember(pub)),
401
+ memberCanSign: (acta, pub, ns = null) => ActaPilar.memberCanSign(acta, pub, ns, subacta.forMember(pub)),
402
+ memberScopes: (acta, pub) => ActaPilar.memberScopes(acta, pub, subacta.forMember(pub)),
403
+ effectiveCaps: (acta, pub) => ActaPilar.effectiveCaps(acta, pub, subacta.forMember(pub))
404
+ }
405
+
406
+ /**
407
+ * ABSORBER lo pendiente en el acta. Solo con la bóveda abierta, que es la única que
408
+ * sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
409
+ * y tirarla sería perder una renuncia sin aplicar.
410
+ */
411
+ async function absorberSubacta (motivo = 'unlock') {
412
+ const pendientes = subacta.drain()
413
+ if (!pendientes.length) return 0
414
+ const hechas = []
415
+ for (const e of pendientes) {
416
+ try { await identity.absorbRenounce(e); hechas.push(e) }
417
+ catch (err) { log(`[vault] ${motivo}: could not absorb a pending renounce: ${err.message}`) }
418
+ }
419
+ if (hechas.length) {
420
+ subacta.clear(hechas)
421
+ log(`[vault] ${motivo}: ${hechas.length} pending renounce(s) absorbed into the record`)
422
+ }
423
+ return hechas.length
424
+ }
425
+
375
426
  /**
376
427
  * LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
377
428
  * `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
@@ -696,6 +747,74 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
696
747
  * el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
697
748
  * todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
698
749
  */
750
+ /**
751
+ * ABRIR LA BÓVEDA DESDE EL ADMIN (`docs/abrir-a-distancia.md`).
752
+ *
753
+ * Dos pasos, y el segundo no se puede reproducir por DOS candados distintos:
754
+ *
755
+ * · el **nonce** hace que se NIEGUE a atenderlo dos veces (memoria, 10 min);
756
+ * · la **efímera** hace que no se PUEDA abrir dos veces — se tira al usarla, así que
757
+ * ese sobre queda inabrible hasta para nosotros, incluso con el disco en la mano.
758
+ *
759
+ * Por qué una efímera y no la llave de cifrado fija de la bóveda: esa cede a la llave de
760
+ * la máquina (modelo de amenazas §4.2), o sea que cae con el disco. La contraseña es
761
+ * justo lo único que un ladrón de disco NO tiene — cifrarla con algo que el disco
762
+ * entrega sería regalársela a quien grabe el tráfico hoy y robe el disco mañana.
763
+ *
764
+ * Y el molino lo hace el ADMIN: aquí llega la derivada, nunca la contraseña.
765
+ */
766
+ const EPHEMERAL_TTL_MS = 60 * 1000
767
+ const aperturas = new Map() // nonce -> { privateKey, exp }
768
+
769
+ const unlockDesk = (typeof openWithSecondary === 'function' && typeof secondaryParams === 'function')
770
+ ? {
771
+ async begin ({ by }) {
772
+ const params = secondaryParams()
773
+ if (!params) {
774
+ throw new Error('this profile has no admin password: set one with `dotrino-vault profile admin-password`')
775
+ }
776
+ // Se barre lo vencido aquí y no con un temporizador: es barato, y un temporizador
777
+ // más es una cosa más que puede quedarse corriendo cuando el perfil ya no existe.
778
+ const t = Date.now()
779
+ for (const [n, v] of aperturas) if (v.exp <= t) aperturas.delete(n)
780
+ const eph = await makeEphemeralKey()
781
+ aperturas.set('pendiente', { privateKey: eph.privateKey, exp: t + EPHEMERAL_TTL_MS })
782
+ audit('unlock.begin', { by })
783
+ return { ...params, ek: eph.ek }
784
+ },
785
+
786
+ async open ({ nonce, enc, by }) {
787
+ const guardada = aperturas.get('pendiente')
788
+ aperturas.delete('pendiente')
789
+ if (!guardada || guardada.exp <= Date.now()) {
790
+ return { ok: false, error: 'unlock: ask for a key first, and use it within a minute' }
791
+ }
792
+ let payload = null
793
+ try { payload = await openSealed({ privateKey: guardada.privateKey, enc }) } catch (_) { payload = null }
794
+ // EL NONCE VA DENTRO DEL SOBRE. Si fuera solo por fuera, un sobre capturado se
795
+ // reenvía con un nonce nuevo y el candado del nonce no sirve de nada.
796
+ if (!payload || payload.nonce !== nonce || typeof payload.key !== 'string') {
797
+ audit('rejected', { what: 'unlock', by, reason: 'sobre' })
798
+ return { ok: false, error: 'unlock: the envelope does not check out' }
799
+ }
800
+ const derivada = new Uint8Array(Buffer.from(payload.key, 'base64'))
801
+ try {
802
+ const r = await openWithSecondary(derivada)
803
+ audit('unlock', { by })
804
+ log(`[vault] opened from the admin by ${by || '????-????'}`)
805
+ return { ok: true, result: { ok: true, locked: false } }
806
+ } catch (e) {
807
+ audit('rejected', { what: 'unlock', by, reason: e.code || 'error' })
808
+ // El freno viaja con el rechazo: sin él, el admin reintenta contra una puerta
809
+ // que ya no responde y no sabe por qué.
810
+ return { ok: false, code: e.code || null, error: e.message, tries: e.tries ?? null, waitSec: e.waitSec ?? null }
811
+ } finally {
812
+ derivada.fill(0)
813
+ }
814
+ }
815
+ }
816
+ : null
817
+
699
818
  const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
700
819
 
701
820
  async function handleProfile (from, p, d) {
@@ -958,7 +1077,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
958
1077
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
959
1078
  running: x.sub ? versionDe(x.sub) : null
960
1079
  })))
961
- reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION }))
1080
+ // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1081
+ // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
1082
+ // Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
1083
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain, vault: MI_VERSION, subacta: subacta.entries() }))
962
1084
  }
963
1085
 
964
1086
  /**
@@ -1844,7 +1966,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1844
1966
  // llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
1845
1967
  // El comentario de aquí decía «la consola lo dice en voz alta» y la consola
1846
1968
  // no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
1847
- hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
1969
+ hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })(),
1970
+ // SI ESTÁ CERRADA, Y SI SE PUEDE ABRIR DESDE AQUÍ. La consola no tenía forma de
1971
+ // saberlo: se enteraba solo al chocar con un `vault-locked` intentando algo. Y
1972
+ // `remoteUnlock` distingue las dos razones por las que el formulario no aparece —
1973
+ // «no está cerrada» y «no hay contraseña de admin puesta»— que son cosas
1974
+ // distintas y llevan a acciones distintas.
1975
+ locked: (() => { try { return !!isLocked() } catch (_) { return true } })(),
1976
+ remoteUnlock: (() => { try { return !!hasSecondary() } catch (_) { return false } })()
1848
1977
  }))
1849
1978
  }
1850
1979
  },
@@ -1945,6 +2074,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1945
2074
  desk,
1946
2075
  deviceIdOf,
1947
2076
  audit,
2077
+ // Abrir a distancia. Va inyectado porque `admin.js` es puro y esto necesita cripto y
2078
+ // disco; `null` si este perfil no lo soporta, y entonces la operación contesta que no.
2079
+ unlockDesk,
1948
2080
  // El MISMO candado que frena firmar y editar el perfil: revocar reescribe el acta y
1949
2081
  // configurar toca los secretos, y ninguna de las dos se hace con la bóveda cerrada.
1950
2082
  isLocked,
@@ -2013,12 +2145,32 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2013
2145
  return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
2014
2146
  }
2015
2147
  try {
2016
- const r = await identity.absorbRenounce(record)
2148
+ // A LA SUBACTA PRIMERO, y aquí está el arreglo: desde este momento surte efecto,
2149
+ // esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
2150
+ // de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
2151
+ // único momento en que de verdad hace falta.
2152
+ const guardada = await subacta.add(record)
2153
+ if (!guardada.ok && guardada.reason === 'no-aceptable') {
2154
+ audit('rejected', { what: 'renounce', reason: 'no-aceptable' })
2155
+ return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
2156
+ }
2017
2157
  const device = await deviceIdOf(record.member).catch(() => null)
2018
2158
  audit('renounce', { device, caps: record.caps })
2019
- log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')}`)
2159
+ log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')} — in effect now`)
2160
+
2161
+ // Y SE SELLA SI SE PUEDE. Con la bóveda abierta entra en el acta y lo respeta toda la
2162
+ // cuenta; cerrada se queda pendiente y lo absorbe el `unlock`. Las dos ramas son
2163
+ // correctas, así que el fallo al sellar NO es un error para quien renunció.
2164
+ let seq = null
2165
+ try {
2166
+ const r = await identity.absorbRenounce(record)
2167
+ subacta.clear([record])
2168
+ seq = r?.seq ?? null
2169
+ } catch (e) {
2170
+ log(`[vault] the renounce stays pending until the vault is opened (${e.message})`)
2171
+ }
2020
2172
  await notifyMembers('renounce', { deviceId: device, caps: record.caps })
2021
- reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq: r?.seq ?? null })
2173
+ reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq, pending: seq === null })
2022
2174
  } catch (e) {
2023
2175
  reply(from, { type: MSG.ERROR, error: 'renounce: ' + e.message })
2024
2176
  }
@@ -2031,7 +2183,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2031
2183
  const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
2032
2184
  // El `code` viaja aparte del texto: el texto está en inglés y puede cambiar, el código
2033
2185
  // es lo que empareja quien llama (`vault-locked` ≠ «no autorizado»).
2034
- if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error, ...(r.code ? { code: r.code } : {}) })
2186
+ // EL FRENO VIAJA CON EL RECHAZO. `tries` y `waitSec` solo los trae abrir a distancia, y
2187
+ // sin ellos el admin reintenta contra una puerta que ya no responde y no sabe por qué —
2188
+ // el mismo silencio que se arregló en el resto del ecosistema.
2189
+ if (!r.ok) {
2190
+ return reply(from, {
2191
+ type: MSG.ERROR,
2192
+ error: r.error,
2193
+ ...(r.code ? { code: r.code } : {}),
2194
+ ...(r.tries != null ? { tries: r.tries } : {}),
2195
+ ...(r.waitSec != null ? { waitSec: r.waitSec } : {})
2196
+ })
2197
+ }
2035
2198
  reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
2036
2199
  }
2037
2200
 
@@ -3019,6 +3182,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3019
3182
  // aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
3020
3183
  // Si falla, no se arrastra: abrir el perfil no puede depender de esto.
3021
3184
  if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
3185
+ // Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
3186
+ // eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
3187
+ // el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
3188
+ // puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
3189
+ if (r?.locked === false) { try { await absorberSubacta('unlock') } catch (_) {} }
3022
3190
  return { locked: r?.locked !== false }
3023
3191
  },
3024
3192
  // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
@@ -3117,7 +3285,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3117
3285
  return {
3118
3286
  ...r,
3119
3287
  vault: MI_VERSION,
3120
- members: (r?.members || []).map((m) => ({ ...m, running: versionDe(m.pub) }))
3288
+ // Y LO QUE RENUNCIÓ Y NO SE HA SELLADO. Ya surte efecto —de eso vive la subacta—
3289
+ // pero si no se ve, el dueño mira la lista, lee los permisos del acta y cree que
3290
+ // ese aparato los tiene. Un freno que no se explica es otro silencio.
3291
+ members: (r?.members || []).map((m) => ({
3292
+ ...m,
3293
+ running: versionDe(m.pub),
3294
+ renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
3295
+ }))
3121
3296
  }
3122
3297
  },
3123
3298
  // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo