@dotrino/vaultd 0.96.0 → 0.98.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/enroll.js CHANGED
@@ -52,7 +52,18 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
55
+ const SCOPE_TO_CAP = {
56
+ 'vault:sign': 'sign',
57
+ 'vault:store': 'store',
58
+ 'vault:read': 'read',
59
+ 'vault:admin': 'admin',
60
+ 'vault:passwords': 'passwords',
61
+ // `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
62
+ // hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
63
+ // nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
64
+ // donde obligar a un segundo paso a mano es el paso que nadie da.
65
+ 'vault:replica': 'replica'
66
+ }
56
67
  export const scopeToCaps = (scope) =>
57
68
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
69
 
@@ -25,7 +25,7 @@ import {
25
25
  makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
26
26
  verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
28
- import { verifyActa, sealKeyAt, sealersOf } from '@dotrino/identity/acta'
28
+ import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
29
29
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
30
30
  import { makeEphemeralKey, openSealed } from './sealed.js'
31
31
  import { parseInvite } from './invite.js'
@@ -241,7 +241,17 @@ function writeServiceIdentity (dir, obj) {
241
241
  * @param {number} [opts.approveTimeoutMs]
242
242
  * @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
243
243
  */
244
- export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000 } = {}) {
244
+ /**
245
+ * @param {boolean} [opts.withEncKey] Estrenar llave de CIFRADO y registrarla en el acta.
246
+ * Por defecto sí: sin ella el aparato entra pero no le llega ningún secreto.
247
+ *
248
+ * Un REPLICADOR entra con `false`, y no es una optimización: es su garantía. Regla del
249
+ * dueño (2026-09-02), *«recibirá todos los sobres que se generen, ningún sobre firmado
250
+ * para él»*. Sin `encPub` en el acta no hay a dónde envolverle nada, así que repartir no
251
+ * puede convertirse en leer ni por descuido ni por un cambio futuro. Es el mismo cerrojo
252
+ * que tiene la llave de comunicación.
253
+ */
254
+ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000, withEncKey = true } = {}) {
245
255
  // `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
246
256
  // `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
247
257
  // invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
@@ -271,14 +281,15 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
271
281
  try {
272
282
  const device = await makeDeviceKey({ label })
273
283
  // La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
274
- // aparato entra al acta pero no le llega ningún secreto, y no da error.
275
- const enc = await makeDeviceEncKey()
284
+ // aparato entra al acta pero no le llega ningún secreto, y no da error. Un replicador
285
+ // entra así A PROPÓSITO (ver `withEncKey`): reparte sobres cerrados y no abre ninguno.
286
+ const enc = withEncKey ? await makeDeviceEncKey() : null
276
287
  const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
277
288
  // Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
278
289
  // aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
279
290
  const code = makePairingCode()
280
291
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
281
- const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc.encPublickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
292
+ const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc?.encPublickey || null, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
282
293
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
283
294
 
284
295
  const enrolled = new Promise((resolve, reject) => {
@@ -313,7 +324,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
313
324
  })
314
325
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
315
326
 
316
- return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
327
+ return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
317
328
  } finally { client.close() }
318
329
  }
319
330
 
@@ -459,10 +470,23 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
459
470
  saved = { ...saved, ...patch }
460
471
  if (dir) { try { writeServiceIdentity(dir, saved) } catch (_) {} }
461
472
  }
462
- /** Hasta qué acta hemos visto: es lo que dispara la renovación la próxima vez. */
473
+ /**
474
+ * Hasta qué acta hemos visto. Dispara la renovación la próxima vez, y desde que hay
475
+ * replicadores es además el freno anti-rollback (`verifyResponder`).
476
+ *
477
+ * SOLO SUBE. Antes escribía cualquier `seq` que llegara, también uno MENOR: bastaba que
478
+ * alguien contestara con un acta vieja para que el pin bajara y dejara de proteger de
479
+ * ahí en adelante. Un pin que retrocede no es un pin — se llama `maxSeq` en el diseño
480
+ * justo por esto (`acta-de-perfil.md` §2.3).
481
+ */
463
482
  const anotarActa = (acta) => {
464
- if (typeof acta?.seq !== 'number' || saved?.actaSeq === acta.seq) return
465
- recordar({ actaSeq: acta.seq })
483
+ if (typeof acta?.seq !== 'number') return
484
+ if (typeof saved?.actaSeq === 'number' && acta.seq <= saved.actaSeq) return
485
+ // Y QUIÉNES REPARTEN. Sale del acta, que llega firmada en cada respuesta, así que no
486
+ // hay una lista que mantener a mano ni un `.env` que se olvide: darle `replica` a un
487
+ // aparato basta para que sus servicios sepan preguntarle cuando la bóveda no esté.
488
+ const replicas = (acta.members || []).filter((m) => memberCan(acta, m.pub, 'replica')).map((m) => m.pub)
489
+ recordar({ actaSeq: acta.seq, replicas })
466
490
  }
467
491
 
468
492
  // DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
@@ -503,16 +527,35 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
503
527
  // poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
504
528
  const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
505
529
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
506
- const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
507
- client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
508
- let res = await pending
530
+ // A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
531
+ // que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
532
+ // lleva una lista de bóvedas y las prueba en orden»).
533
+ //
534
+ // Sin esto, un replicador no servía absolutamente para nada: el mensaje se mandaba a
535
+ // la llave de la bóveda y, con la bóveda apagada, no llegaba a ninguna parte. Lo
536
+ // destapó el smoke con las dos máquinas encendidas y la bóveda muerta, que es
537
+ // exactamente el caso que un replicador existe para cubrir.
538
+ //
539
+ // La bóveda va PRIMERO siempre: un replicador solo puede estar igual de al día o menos.
540
+ const destinos = [masterPubkey, ...(saved?.replicas || []).filter((k) => k !== masterPubkey)]
541
+ // El plazo se reparte: no tiene sentido esperar el timeout entero a una bóveda apagada
542
+ // y quedarse sin tiempo para preguntarle a quien sí está.
543
+ const porDestino = Math.max(4000, Math.round(timeoutMs / destinos.length))
544
+ let res = null
545
+ let ultimo = null
546
+ for (const destino of destinos) {
547
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, porDestino)
548
+ client.sendByPubkey(destino, { type: MSG.SECRETS, data, signature, cert })
549
+ try { res = await pending; break } catch (e) { ultimo = e }
550
+ }
551
+ if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
509
552
  if (res.type === MSG.ERROR) throw new Error(res.error)
510
553
 
511
554
  // CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
512
555
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
513
556
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
514
557
  if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
515
- await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
558
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
516
559
  anotarActa(res.acta)
517
560
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
518
561
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
@@ -534,7 +577,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
534
577
  // confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
535
578
  // dentro se usa hasta después de verificar.
536
579
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
537
- await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
580
+ await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
538
581
  anotarActa(payload?.acta)
539
582
 
540
583
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
@@ -578,8 +621,32 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
578
621
  * Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
579
622
  * quisiera.
580
623
  */
581
- export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
582
- if (!seal?.sig || typeof seal.seq !== 'number') {
624
+ /**
625
+ * ¿QUIÉN PUEDE HABERME CONTESTADO ESTO? Hay dos respuestas legítimas y una condición.
626
+ *
627
+ * · **La bóveda.** Firma con la llave de sellado que el acta nombra para ese `seq`. Es
628
+ * el camino de siempre y no cambia.
629
+ * · **Un REPLICADOR** (`seal.by`). No tiene esa llave —no tiene maestra— así que firma
630
+ * con la suya de aparato, y lo que la autoriza es que el acta le reconozca `replica`.
631
+ * Reparte lo que la bóveda ya selló: los sobres de dentro se siguen comprobando contra
632
+ * la llave de sellado (`makeSealCheck`), así que un replicador no puede inventarse
633
+ * contenido, solo entregarlo.
634
+ *
635
+ * LA CONDICIÓN, y es la que cierra el agujero que `replicas.md` §6.1 llama «construir el
636
+ * ataque»: **un replicador solo le contesta a quien YA conoce la cuenta.** Un replicador
637
+ * atrasado presenta un acta donde un aparato revocado sigue siendo miembro; contra eso, a
638
+ * quien tiene `maxSeq` pineado lo protege el pin, pero a un aparato que llega nuevo no hay
639
+ * con qué compararlo. Así que sin pin no se le cree: que conteste la bóveda.
640
+ *
641
+ * El precio, dicho en voz alta: un aparato nuevo necesita la bóveda encendida una vez. El
642
+ * oráculo de frescura (§2.4.2 del acta) es lo que quitaría ese precio; mientras no exista,
643
+ * esto es el freno, y es un freno de verdad y no un aviso.
644
+ *
645
+ * @param {number|null} [knownSeq] El `seq` más alto que este agente ya vio de esta cuenta.
646
+ * `null`/ausente = no conoce la cuenta, y entonces un replicador no vale.
647
+ */
648
+ export async function verifyResponder ({ body, seal, acta, masterPubkey, knownSeq = null }) {
649
+ if (!seal?.sig) {
583
650
  throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
584
651
  }
585
652
  if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
@@ -587,6 +654,33 @@ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
587
654
  throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
588
655
  }
589
656
  if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
657
+
658
+ // NUNCA HACIA ATRÁS. Vale para los dos caminos: un acta con `seq` menor del que ya
659
+ // vimos es un rollback, venga de donde venga.
660
+ if (typeof knownSeq === 'number' && typeof acta.seq === 'number' && acta.seq < knownSeq) {
661
+ throw Object.assign(
662
+ new Error(`the record went backwards (#${acta.seq} after #${knownSeq}): refusing the reply`),
663
+ { code: 'stale-record' })
664
+ }
665
+
666
+ if (seal.by) {
667
+ if (typeof knownSeq !== 'number') {
668
+ throw Object.assign(
669
+ new Error('a replica answered, and this device has never seen the account: ask the vault itself the first time'),
670
+ { code: 'replica-unknown-account' })
671
+ }
672
+ if (!memberCan(acta, seal.by, 'replica')) {
673
+ throw new Error('the reply comes from a device the record does not allow to serve as a replica')
674
+ }
675
+ if (!(await verifyDeviceSig({ publickey: seal.by, data: body, signature: seal.sig }))) {
676
+ throw new Error('the replica signature does not check out')
677
+ }
678
+ return
679
+ }
680
+
681
+ if (typeof seal.seq !== 'number') {
682
+ throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
683
+ }
590
684
  const pub = sealKeyAt(acta, seal.seq)
591
685
  if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
592
686
  if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.96.0",
3
+ "version": "0.98.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": {
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "0.76.0",
23
+ "@dotrino/identity": "0.79.0",
24
24
  "@dotrino/passmanager": "^0.10.1",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -254,6 +254,10 @@ async function cmdPair (args = []) {
254
254
  if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
255
255
  if (t === 'sealer' || t === 'sella') { console.error('`sella` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +sella'); process.exit(2) }
256
256
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
257
+ // Un REPLICADOR nace acotado: solo esto, ni firma ni lee ni guarda. Se empareja
258
+ // —al contrario que `sella`— porque se despliega en una máquina sin teclado y el
259
+ // permiso no concede nada que haya que pensarse dos veces: reparte sobres cerrados.
260
+ if (t === 'replica' || t === 'replicador') { scope.push('vault:replica'); continue }
257
261
  // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
258
262
  // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
259
263
  if (t === 'passwords') { scope.push('vault:passwords'); continue }
@@ -1606,7 +1610,7 @@ function help () {
1606
1610
  El QR no lleva nada: el permiso se aplica al aprobar el código aquí
1607
1611
  pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
1608
1612
  esperar. Para desplegar (un contenedor no mira un QR en pantalla)
1609
- pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1613
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,replica,secrets:<ns> (sin esto: sign,read,store;
1610
1614
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1611
1615
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
1612
1616
  aparatos del perfil que sirven ese namespace
@@ -1646,6 +1650,10 @@ function help () {
1646
1650
  KMS, y solo vale ahora: el perfil se está creando)
1647
1651
  Al desplegar, esto mismo va por el entorno y sin entrar a nada:
1648
1652
  DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
1653
+ replica enroll <invitación>
1654
+ esta máquina entra como REPLICADOR: reparte los sobres que la
1655
+ bóveda ya firmó cuando ella no está, y no puede abrir ninguno ni
1656
+ cambiar nada. No tiene maestra. Arráncalo con: replica run
1649
1657
  pending muestra el dispositivo pendiente + su código a comparar
1650
1658
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
1651
1659
  reject <deviceId> rechaza un dispositivo pendiente
@@ -1697,6 +1705,82 @@ Arrancar y parar, según dónde corra:
1697
1705
  (el CLI, dentro: docker exec -it dotrino-vault dotrino-vault status)`)
1698
1706
  }
1699
1707
 
1708
+ /**
1709
+ * `replica` — LA OTRA PIEZA, la que reparte sin decidir (`docs/replicas.md` §8.bis).
1710
+ *
1711
+ * dotrino-vault replica enroll <invitación> entra en la cuenta, UNA vez
1712
+ * dotrino-vault replica run se queda repartiendo
1713
+ * dotrino-vault replica status qué acta tiene y cuántos sobres
1714
+ *
1715
+ * No corre dentro del daemon del vault y no es un perfil: un replicador NO TIENE MAESTRA,
1716
+ * y meterlo en el gestor de perfiles sería fabricarle una. Vive en su propio directorio
1717
+ * (`DOTRINO_REPLICA_DIR`, por defecto `~/.dotrino/replica`).
1718
+ */
1719
+ async function cmdReplica (args) {
1720
+ const { replicaDir, openReplicaStore, runReplica, REPLICA_SCOPE } = await import('./replica.js')
1721
+ const sub = args[0]
1722
+ const dir = process.env.DOTRINO_REPLICA_DIR || replicaDir()
1723
+
1724
+ if (sub === 'enroll') {
1725
+ const invitacion = args.slice(1).filter((a) => !a.startsWith('-')).join(' ')
1726
+ if (!invitacion) {
1727
+ console.error('uso: dotrino-vault replica enroll "<invitación>"')
1728
+ console.error(' la invitación sale de la bóveda: dotrino-vault pair --scope replica --quiet')
1729
+ process.exit(2)
1730
+ }
1731
+ const { enrollWithVault } = await import('../lib/src/service.js')
1732
+ // `withEncKey: false` NO es una optimización: es la garantía. Sin llave de cifrado en
1733
+ // el acta no hay a dónde envolverle un sobre, así que repartir no puede convertirse en
1734
+ // leer. Regla del dueño: «recibirá todos los sobres que se generen, ningún sobre
1735
+ // firmado para él».
1736
+ const r = await enrollWithVault({
1737
+ qr: invitacion,
1738
+ label: args.includes('--label') ? args[args.indexOf('--label') + 1] : 'replicador',
1739
+ expectedScope: REPLICA_SCOPE,
1740
+ withEncKey: false,
1741
+ onCode: ({ deviceId, code }) => {
1742
+ console.log('\n Tipea ESTE código en la bóveda para aprobarlo:')
1743
+ console.log(` código: ${code}`)
1744
+ console.log(` máquina: ${deviceId}`)
1745
+ console.log(` (en la bóveda: dotrino-vault approve ${code})\n`)
1746
+ console.log(' Esperando…')
1747
+ }
1748
+ })
1749
+ openReplicaStore(dir).setLink({ device: r.device, cert: r.cert, iss: r.iss, proxy: r.proxy })
1750
+ console.log('\n Listo: este replicador ya está en la cuenta.')
1751
+ console.log(' Arráncalo con: dotrino-vault replica run\n')
1752
+ return
1753
+ }
1754
+
1755
+ if (sub === 'status') {
1756
+ const st = openReplicaStore(dir)
1757
+ if (!st.link) { console.log('Sin enrolar. Empieza por: dotrino-vault replica enroll "<invitación>"'); return }
1758
+ console.log('Replicador · datos en %s', dir)
1759
+ console.log(' acta : #%s%s', st.seq || 0, st.acta ? '' : ' (todavía ninguna: la manda la bóveda)')
1760
+ console.log(' sobres : %d', st.bundles)
1761
+ console.log(' bóveda : %s…', String(st.link.iss).slice(0, 32))
1762
+ return
1763
+ }
1764
+
1765
+ if (sub === 'run' || sub === undefined) {
1766
+ try {
1767
+ await runReplica({ dir })
1768
+ } catch (e) {
1769
+ if (e?.code === 'replica-not-enrolled') {
1770
+ console.error('\n Este replicador todavía no está en ninguna cuenta.\n')
1771
+ console.error(' En la bóveda: dotrino-vault pair --scope replica --quiet')
1772
+ console.error(' Y aquí, con eso: dotrino-vault replica enroll "<invitación>"\n')
1773
+ process.exit(2)
1774
+ }
1775
+ throw e
1776
+ }
1777
+ await new Promise(() => {})
1778
+ return
1779
+ }
1780
+ console.error('uso: dotrino-vault replica [enroll <invitación>|run|status]')
1781
+ process.exit(2)
1782
+ }
1783
+
1700
1784
  export async function runCtl (argv) {
1701
1785
  const [cmd, ...rest] = takeProfileFlag(argv)
1702
1786
  switch (cmd) {
@@ -1722,6 +1806,7 @@ export async function runCtl (argv) {
1722
1806
  case 'activity': return cmdActivity(Number(rest.find((a) => /^\d+$/.test(a))) || 30, {
1723
1807
  verify: rest.includes('--verify'), exportar: rest.includes('--export')
1724
1808
  })
1809
+ case 'replica': return cmdReplica(rest)
1725
1810
  case 'logs': return cmdLogs()
1726
1811
  case 'version':
1727
1812
  case '--version':
package/src/daemon.js CHANGED
@@ -191,7 +191,19 @@ export async function runDaemon () {
191
191
  // `vault:secrets:<ns>`. Se valida aquí porque es la maestra la que firma: nada
192
192
  // que no esté en esta lista entra en un cert, y `vault:admin` / `vault:approve` nunca
193
193
  // por este camino (se conceden a mano con `caps`).
194
- const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
194
+ //
195
+ // LA LISTA VA EXPLÍCITA A PROPÓSITO, y es la única del ecosistema que debe estarlo:
196
+ // aquí no se dibuja una pantalla, se decide qué firma la maestra. Derivarla del
197
+ // pilar significaría que un permiso nuevo se pudiera emparejar el día que alguien lo
198
+ // añade, sin que nadie lo haya pensado. `vault:admin` y `vault:approve` no están, y
199
+ // esa ausencia ES la regla.
200
+ //
201
+ // `vault:passwords` faltaba y el CLI sí lo ofrecía: emparejar el gestor de
202
+ // contraseñas con su permiso se rechazaba aquí. `vault:replica` entra el 2026-09-02:
203
+ // un replicador reparte sobres que no puede abrir, así que se puede emparejar —al
204
+ // revés que sellar o administrar— y se despliega donde no hay teclado.
205
+ const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' ||
206
+ x === 'vault:passwords' || x === 'vault:replica' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
195
207
  const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
196
208
  if (asked && asked.some((x) => !ALLOWED(x))) {
197
209
  ipcWrite(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
@@ -333,10 +345,13 @@ export async function runDaemon () {
333
345
  // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
334
346
  // reemplazando lo que alguien metiera mal y quitando lo que sobre.
335
347
  const r = await mgr.get(id)?.resealAll?.(ak)
336
- if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s',
337
- r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '')
348
+ if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s%s',
349
+ r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '',
350
+ r.sealed ? `, ${r.sealed} value(s) left in the clear now sealed` : '')
338
351
  if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
339
352
  else if (r?.wrapped) note = ' · llavero al día'
353
+ // Se SUMA, no pisa: es lo que el dueño necesita ver de esta apertura en concreto.
354
+ if (r?.sealed) note += ` · ${r.sealed} valor(es) que estaban sin cifrar ya están sellados`
340
355
  // ABIERTA PERO SIN LLAVERO NO ES «ABIERTA». Si un cajón no se pudo reenvolver, sus
341
356
  // aparatos no van a poder leer nada y hay que decirlo AQUÍ: contestar
342
357
  // «desbloqueado» a secas dejaba la bóveda pareciendo sana, y el fallo aparecía
package/src/replica.js ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * replica.js — UNA BÓVEDA QUE REPARTE Y NO DECIDE.
3
+ *
4
+ * Diseño: `docs/replicas.md` §8.bis. Es la otra pieza del par, y NO sustituye al
5
+ * multivault: una segunda bóveda tiene maestra y sella; un replicador no tiene maestra y
6
+ * no sella nunca. Las dos se conservan.
7
+ *
8
+ * Qué guarda, y no hay una cuarta cosa:
9
+ *
10
+ * · su LLAVE DE APARATO y su papel — para ser alcanzable y para firmar lo que contesta;
11
+ * · el ACTA vigente — pública y firmada, la verifica quien la recibe;
12
+ * · los SOBRES — que ya vienen cerrados a su destinatario.
13
+ *
14
+ * Por qué se puede poner en una máquina ajena, que es el punto entero: **no puede abrir
15
+ * nada de lo que reparte**. Entra al acta SIN llave de cifrado (`withEncKey: false`), así
16
+ * que no existe camino por el que le llegue un sobre dirigido a él — regla del dueño:
17
+ * «recibirá todos los sobres que se generen, ningún sobre firmado para él». Que lo
18
+ * comprometan cuesta disponibilidad, no confidencialidad.
19
+ *
20
+ * Y lo que NO hace, comprobado y no prometido: no sella actas, no emite certificados, no
21
+ * admite aparatos, no cambia permisos. Si algo de eso le llega, contesta que no.
22
+ */
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import os from 'node:os'
26
+ import { WebSocketProxyClient } from '@dotrino/proxy-client'
27
+ import { signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
28
+ import { verifyActa, memberCan, sealKeyAt } from '@dotrino/identity/acta'
29
+ import { MSG } from '../lib/src/protocol.js'
30
+ import { seal } from '../lib/src/sealed.js'
31
+ import { atRestFor } from './atrest.js'
32
+ import { readJson, writeJson } from './paths.js'
33
+
34
+ export const REPLICA_SCOPE = 'vault:replica'
35
+
36
+ export const replicaDir = () =>
37
+ process.env.DOTRINO_REPLICA_DIR || path.join(os.homedir(), '.dotrino', 'replica')
38
+
39
+ const LINK = 'link.json'
40
+ const STATE = 'state.json'
41
+
42
+ /** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
43
+ export const bundleKey = (ns, devicePub) => `${ns} ${devicePub}`
44
+
45
+ /** Lo que este replicador sabe de la cuenta: su papel, el acta y los sobres. */
46
+ export function openReplicaStore (dir) {
47
+ fs.mkdirSync(dir, { recursive: true })
48
+ const atRest = atRestFor(dir)
49
+ const leer = (f, d) => readJson(path.join(dir, f), d, atRest)
50
+ const escribir = (f, v) => writeJson(path.join(dir, f), v, atRest)
51
+
52
+ let link = leer(LINK, null)
53
+ let state = leer(STATE, null) || { seq: 0, acta: null, bundles: {} }
54
+
55
+ return {
56
+ get link () { return link },
57
+ get acta () { return state.acta },
58
+ get seq () { return state.seq },
59
+ setLink (v) { link = v; escribir(LINK, v) },
60
+ bundleFor (ns, devicePub) { return state.bundles[bundleKey(ns, devicePub)] || null },
61
+ get bundles () { return Object.keys(state.bundles).length },
62
+ /**
63
+ * SOLO HACIA ADELANTE. Es el mismo freno que el pin del cliente: un empujón con un
64
+ * acta más vieja de la que ya tenemos es un rollback, y aceptarlo convertiría al
65
+ * replicador en el camino para servir un acta donde un aparato revocado sigue dentro.
66
+ * @returns {boolean} si se aplicó (false = venía atrasado y se descartó)
67
+ */
68
+ apply ({ seq, acta, bundles }) {
69
+ if (typeof seq !== 'number' || seq < state.seq) return false
70
+ state = { seq, acta: acta || state.acta, bundles: { ...state.bundles, ...(bundles || {}) } }
71
+ escribir(STATE, state)
72
+ return true
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Arranca el replicador. No crea ninguna maestra ni ningún perfil: si no está enrolado,
79
+ * lo dice y se para. Estrenar una identidad aquí sería justo lo que no debe existir.
80
+ */
81
+ export async function runReplica ({ dir = replicaDir(), proxyUrl, log = console.log } = {}) {
82
+ const store = openReplicaStore(dir)
83
+ const link = store.link
84
+ if (!link?.device?.privateJwk || !link?.cert || !link?.iss) {
85
+ throw Object.assign(
86
+ new Error('this replica is not enrolled yet: run `dotrino-vault replica enroll <invitation>` here first'),
87
+ { code: 'replica-not-enrolled' })
88
+ }
89
+ const url = proxyUrl || link.proxy || process.env.PROXY_URL || 'wss://proxy.dotrino.com'
90
+ const id = (await pubkeyId(link.device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
91
+
92
+ const firmar = (data) => signWithDevice({ privateJwk: link.device.privateJwk, data })
93
+ // CONTESTAR es por la CONEXIÓN (`send`) y no por la llave (`sendByPubkey`): `from` es el
94
+ // identificador de quien escribe en el proxio, no su pubkey. Mandar por llave a un `from`
95
+ // no falla — se va a la nada, y quien preguntó se queda esperando el plazo entero.
96
+ // Por llave solo se habla con la BÓVEDA (`link.iss`), que es a quien queremos alcanzar
97
+ // aunque no esté conectada ahora mismo.
98
+
99
+ const client = new WebSocketProxyClient({ url })
100
+ await client.connect()
101
+ const data = { op: 'identify', publickey: link.device.publickey, token: client.token, ts: Date.now() }
102
+ await client.identify({ data, signature: (await firmar(data)).signature })
103
+ log(`[replica] ${id} · connected to ${url}`)
104
+ log(`[replica] record #${store.seq}${store.acta ? '' : ' · no record yet: waiting for the vault'}`)
105
+
106
+ /**
107
+ * UN EMPUJÓN DE LA BÓVEDA. Va firmado con la llave de sellado que el acta nombra, así
108
+ * que se comprueba de dónde viene antes de guardar nada. Y se comprueba contra el acta
109
+ * QUE VIENE, después de verificar que la selló la maestra que este replicador conoce:
110
+ * fiarse de la que ya tiene impediría para siempre recibir una nueva.
111
+ */
112
+ async function onPush (from, p) {
113
+ const body = p?.body
114
+ if (!body || body.op !== 'replica.push') return
115
+ const acta = body.acta || store.acta
116
+ if (!acta) return log('[replica] a push arrived with no record and none is stored: ignored')
117
+ if (acta.sealedBy !== link.iss) return log('[replica] the pushed record was sealed by another master: ignored')
118
+ if (!(await verifyActa({ acta })).ok) return log('[replica] the pushed record does not verify: ignored')
119
+ const pub = sealKeyAt(acta, p.seal?.seq)
120
+ if (!pub || !(await verifyDeviceSig({ publickey: pub, data: body, signature: p.seal?.sig }))) {
121
+ return log('[replica] the push is not signed by the key the record names: ignored')
122
+ }
123
+ if (!memberCan(acta, link.device.publickey, 'replica')) {
124
+ return log('[replica] the record no longer lets this device serve as a replica: ignored')
125
+ }
126
+ if (!store.apply({ seq: body.seq, acta, bundles: body.bundles })) {
127
+ return log(`[replica] a push arrived with an older record (#${body.seq} < #${store.seq}): refused`)
128
+ }
129
+ log(`[replica] record #${store.seq} · ${Object.keys(body.bundles || {}).length} bundle(s) stored`)
130
+ const ack = { op: 'replica.ack', publickey: link.device.publickey, seq: store.seq, ts: Date.now() }
131
+ client.send(from, { type: MSG.REPLICA_ACK, body: ack, signature: (await firmar(ack)).signature })
132
+ }
133
+
134
+ /**
135
+ * UNA PETICIÓN DE CLAVES. Se contesta con lo que hay guardado, cerrado a la efímera de
136
+ * quien pregunta y firmado con NUESTRA llave — que es lo que el cliente acepta cuando el
137
+ * acta nos reconoce `replica` (`verifyResponder` de `@dotrino/vault`).
138
+ *
139
+ * Lo que NO se decide aquí, y es a propósito: si quien pide tiene derecho a ese cajón.
140
+ * Lo dice el sobre, no nosotros — va cerrado a su llave, así que a quien no le toque le
141
+ * llega algo que no puede abrir. Un replicador que decidiera quién lee sería un
142
+ * replicador del que hay que fiarse, y la gracia es que no haga falta.
143
+ */
144
+ async function onSecrets (from, p) {
145
+ const ns = p?.data?.ns
146
+ const ek = p?.data?.ek
147
+ const quien = p?.data?.publickey
148
+ if (typeof ns !== 'string' || !ek || !quien) return
149
+ if (!store.acta) return client.send(from, { type: MSG.ERROR, error: 'replica: no record yet' })
150
+ const b = store.bundleFor(ns, quien)
151
+ if (!b) {
152
+ log(`[replica] asked for "${ns}" and has nothing stored for that device`)
153
+ return client.send(from, {
154
+ type: MSG.ERROR,
155
+ error: `replica: nothing stored for "${ns}" and this device — ask the vault once while it is up`
156
+ })
157
+ }
158
+ const enc = await seal({ ek, payload: { sealed: b, acta: store.acta } })
159
+ const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
160
+ // `by` y no `seq`: no tenemos la llave de sellado y no debemos tenerla. Quien recibe
161
+ // mira el acta, ve que a esta llave le reconoce `replica`, y acepta.
162
+ const { signature } = await firmar(body)
163
+ client.send(from, { type: MSG.SECRETS_RESULT, body, seal: { by: link.device.publickey, sig: signature } })
164
+ log(`[replica] served "${ns}" from record #${store.seq}`)
165
+ }
166
+
167
+ /** Todo lo que DECIDE algo se contesta que no, y se dice por qué. */
168
+ const NO_DECIDE = new Set([MSG.ENROLL, MSG.ADMIN, MSG.RENEW, MSG.RENOUNCE, MSG.SIGN, MSG.ACTA_SEALED])
169
+
170
+ client.on('message', (from, p) => {
171
+ const t = p?.type
172
+ if (t === MSG.REPLICA_PUSH) return onPush(from, p).catch((e) => log('[replica] push: ' + e.message))
173
+ if (t === MSG.SECRETS) return onSecrets(from, p).catch((e) => log('[replica] secrets: ' + e.message))
174
+ if (NO_DECIDE.has(t)) {
175
+ log(`[replica] refused "${t}": a replica hands things out, it does not decide`)
176
+ return client.send(from, {
177
+ type: MSG.ERROR,
178
+ error: 'replica: this is a replica — it has no master and decides nothing'
179
+ })
180
+ }
181
+ })
182
+
183
+ // EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Se dice por dónde vamos en cuanto hay
184
+ // conexión: si la bóveda va por delante, contesta con un empujón. Sin esto, un
185
+ // replicador recién encendido se queda vacío hasta que alguien cambie algo — y el
186
+ // empujón del propio enrolamiento se manda cuando este proceso todavía no existe.
187
+ const saludo = { op: 'replica.ack', publickey: link.device.publickey, seq: store.seq, ts: Date.now() }
188
+ try {
189
+ client.sendByPubkey(link.iss, { type: MSG.REPLICA_ACK, body: saludo, signature: (await firmar(saludo)).signature })
190
+ } catch (e) { log('[replica] could not say hello to the vault: ' + e.message) }
191
+
192
+ log('[replica] ready · it hands out what the vault already signed, and nothing else')
193
+ return {
194
+ id,
195
+ publickey: link.device.publickey,
196
+ seq: () => store.seq,
197
+ stop: () => { try { client.close() } catch (_) {} }
198
+ }
199
+ }
@@ -834,11 +834,44 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
834
834
  if (!bag[k]) return { wrapped: 0, sinLlave: [] }
835
835
  const priv = await openRecovery(adminKey)
836
836
  let wrapped = 0
837
+ let sealed = 0
837
838
  const sinLlave = new Set()
839
+ const vars = varsOf(bag, k)
840
+ const paraVisibilidad = async (esPublica) => (typeof members === 'function' ? members(esPublica) : members)
841
+
842
+ // UN VALOR QUE SE QUEDÓ EN CLARO SE SELLA AQUÍ, que es el único sitio donde se puede.
843
+ //
844
+ // Una pública escrita antes del 2026-09-02 se guardó legible y SIN sobre, así que no
845
+ // tiene generación ni entrada en el llavero. Y el llavero es lo único que este
846
+ // método reenvuelve: por muchas veces que se abra la bóveda, a esa variable no se le
847
+ // crea una envoltura nunca. Sus aparatos la reclaman para siempre («no key yet») y la
848
+ // consola invita a abrir la bóveda, que es justo lo que no lo arregla. Peor: desde
849
+ // 2026-09-02 el agente rechaza el cajón ENTERO por ella, así que una variable de
850
+ // prueba deja sin leer el token que está al lado.
851
+ //
852
+ // Con la bóveda abierta el valor está delante, así que se cifra como si se acabara
853
+ // de escribir. No es un repliegue ni una migración a medias: es la conversión que
854
+ // faltaba, y ocurre en el único momento en que hay con qué hacerla.
855
+ for (const [key, e] of Object.entries(vars)) {
856
+ if (typeof e.v !== 'string' || e.e) continue
857
+ const cek = await sealer.newKey()
858
+ const gen = (topGen(bag, k)?.gen || 0) + 1
859
+ const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
860
+ r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
861
+ const sobre = await sealer.encrypt(cek, e.v, gen)
862
+ const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
863
+ // `pub` y `at` se conservan: no es un valor nuevo, es el mismo mejor guardado.
864
+ vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: e.at || Date.now(), by: e.by || null }
865
+ bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps: r.wraps }]
866
+ for (const x of r.sinLlave) sinLlave.add(x)
867
+ sealed++
868
+ }
869
+
838
870
  // De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
871
+ // Se calcula DESPUÉS de sellar, para que las generaciones recién creadas entren.
839
872
  const visibilidad = new Map()
840
- for (const e of Object.values(varsOf(bag, k))) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
841
- const paraGen = async (gen) => (typeof members === 'function' ? members(visibilidad.get(gen) === true) : members)
873
+ for (const e of Object.values(vars)) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
874
+ const paraGen = async (gen) => paraVisibilidad(visibilidad.get(gen) === true)
842
875
  for (const g of bag[k].keyring || []) {
843
876
  const w = g.wraps?.[RECOVERY]
844
877
  if (!w) continue
@@ -859,7 +892,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
859
892
  wrapped += Object.keys(r.wraps).length
860
893
  }
861
894
  save()
862
- return { wrapped, sinLlave: [...sinLlave] }
895
+ return { wrapped, sealed, sinLlave: [...sinLlave] }
863
896
  },
864
897
 
865
898
  /**
package/src/tui/app.js CHANGED
@@ -37,6 +37,7 @@ import { qrToString } from '../qr.js'
37
37
  import { dict, otherLang, loadLang, saveLang } from './i18n.js'
38
38
  import * as vc from '../vaultControl.js'
39
39
  import { VERSION } from '../version.js'
40
+ import { DEVICE_CAPS } from '@dotrino/identity/acta'
40
41
 
41
42
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
42
43
 
@@ -338,7 +339,18 @@ function pairModeRows (st, t) {
338
339
  * permiso que hay que entender para un servidor: dice si esa máquina se lleva tus claves
339
340
  * privadas sola o si te lo pregunta. Verlo apagado es la mitad del mensaje.
340
341
  */
341
- const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended']
342
+ /**
343
+ * EL ORDEN LO ELIGE ESTA PANTALLA; LA LISTA LA MANDA EL ACTA.
344
+ *
345
+ * Antes esto era una lista suelta escrita a mano, y por eso cada permiso nuevo se quedó
346
+ * invisible aquí durante semanas —`sealer`, `unattended` y `secrets`, tres veces el mismo
347
+ * fallo—. Ahora el orden se cura a mano, que es una decisión de diseño, pero lo que no
348
+ * esté nombrado se añade al final: un permiso puede salir en mal sitio, nunca desaparecer.
349
+ * `test/tui-permisos.test.mjs` exige además que todos tengan nombre en los dos idiomas.
350
+ */
351
+ const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica']
352
+ const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
353
+ ...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
342
354
 
343
355
  /**
344
356
  * Los permisos que se le pueden dar A ESTE miembro, en orden.
@@ -1349,6 +1361,8 @@ async function toggleVisibility (term, st, wasPublic, apply) {
1349
1361
  if (r.ok) { flash(st, wasPublic ? i.nowPrivate : i.nowPublic); st.secrets = r.v }
1350
1362
  }
1351
1363
  if (wasPublic) return run()
1364
+ // Solo se pregunta al ABRIRLA. Volverla privada es la dirección segura y se hace sin
1365
+ // confirmar: pedir permiso para cerrar algo solo entrena a decir que sí.
1352
1366
  setConfirm(st, { text: i.makePublicConfirm, onYes: run, onNo: () => { st.confirm = null } })
1353
1367
  }
1354
1368
 
@@ -1394,17 +1408,23 @@ async function onKeyDevVars (term, st, key) {
1394
1408
  }
1395
1409
 
1396
1410
  /**
1397
- * Al crear una variable se PREGUNTA si su valor puede salir de esta máquina. Se pregunta
1398
- * al crearla, y no después, porque es cuando quien la escribe sabe qué es: un puerto se
1399
- * puede enseñar, una llave de producción no. La respuesta por defecto —Enter, o `n`— es
1400
- * la privada.
1411
+ * Al crear una variable se pregunta SI ES PRIVADA. Se pregunta al crearla, y no después,
1412
+ * porque es cuando quien la escribe sabe qué es: un puerto se puede enseñar, una llave de
1413
+ * producción no.
1414
+ *
1415
+ * OJO AL SENTIDO. Antes se preguntaba lo contrario («¿que se pueda ver?») y el defecto
1416
+ * —Enter o Esc— caía en `onNo`, que era la privada, o sea la segura. Al dar la vuelta a la
1417
+ * pregunta, ese mismo defecto pasaría a ser la PÚBLICA sin que nadie lo notara: cada
1418
+ * variable nueva se entregaría sin pedir permiso. Por eso el defecto se declara aquí
1419
+ * (`defaultYes`) en vez de heredarse del manejador de teclas.
1401
1420
  */
1402
1421
  function askVisibility (term, st, done) {
1403
1422
  const i = L(st)
1404
1423
  setConfirm(st, {
1405
- text: i.newVarPublicAsk,
1406
- onYes: () => { st.confirm = null; done(true) },
1407
- onNo: () => { st.confirm = null; done(false) }
1424
+ text: i.newVarPrivateAsk,
1425
+ defaultYes: true,
1426
+ onYes: () => { st.confirm = null; done(false) }, // privada
1427
+ onNo: () => { st.confirm = null; done(true) } // pública
1408
1428
  })
1409
1429
  }
1410
1430
 
@@ -1554,7 +1574,14 @@ async function onConfirmKey (st, key) {
1554
1574
  // La tecla es `y` (yes) en los dos idiomas —como el resto, mnemónico inglés—;
1555
1575
  // `s` (sí) se sigue aceptando por costumbre, pero no se anuncia en la ayuda.
1556
1576
  if (ch === 's' || ch === 'y') { const f = cf.onYes; st.confirm = null; await f?.() }
1557
- else if (ch === 'n' || key.name === 'escape' || key.name === 'enter' || key.name === 'ctrl-c') { const f = cf.onNo; st.confirm = null; await f?.() }
1577
+ else if (ch === 'n') { const f = cf.onNo; st.confirm = null; await f?.() }
1578
+ // Enter y Esc toman el CAMINO SEGURO, y cuál es lo dice quien pregunta. Sin esto el
1579
+ // defecto era siempre «no», y una pregunta formulada al revés lo volvía el peligroso.
1580
+ else if (key.name === 'escape' || key.name === 'enter' || key.name === 'ctrl-c') {
1581
+ const f = cf.defaultYes ? cf.onYes : cf.onNo
1582
+ st.confirm = null
1583
+ await f?.()
1584
+ }
1558
1585
  }
1559
1586
 
1560
1587
  // --------------------------------- render ----------------------------------
package/src/tui/i18n.js CHANGED
@@ -39,45 +39,52 @@ const es = {
39
39
  // PERMISOS de un dispositivo (§9.1: se dice el beneficio, no el scope del cert).
40
40
  titleCaps: 'Permisos del dispositivo',
41
41
  capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
42
- capsNoMember: ' (este dispositivo ya no está en el acta)',
42
+ capsNoMember: ' (este dispositivo ya no está en el perfil)',
43
43
  capsApplyHint: 'Enter marca o desmarca · G guarda los cambios · Esc sale',
44
- capsPending: (n) => `${n} cambio(s) sin guardar (*) · G para guardarlos en UNA sola acta · Esc para descartarlos`,
45
- capsSaved: (n) => `${n} cambio(s) guardados en una sola acta`,
44
+ capsPending: (n) => `${n} cambio(s) sin guardar (*) · G para guardarlos todos de una vez · Esc para descartarlos`,
45
+ capsSaved: (n) => `${n} cambio(s) guardados de una vez`,
46
46
  capsNothing: 'No has cambiado nada',
47
47
  capsDiscard: '¿Descartar los cambios sin guardar?',
48
+ // EL NOMBRE DICE EL ACTO, NO LA CONSECUENCIA (dueño, 2026-09-02: «que digan exactamente
49
+ // lo que hacen y no lo que implican»). «Sellar el acta» y «Administrar el perfil» eran
50
+ // lo segundo: hay que saberse el modelo para traducirlos. Lo que hacen es admitir
51
+ // aparatos y conectar o quitar dispositivos, y eso es lo que se lee ahora.
48
52
  capName: {
49
53
  // Lleva el cajón dentro: «las claves de proxy» dice QUÉ abre; «lee sus claves» te deja
50
54
  // preguntándote cuáles. Solo se le ofrece a un servicio (los que tienen `cn`).
51
55
  secrets: (cn) => `Leer las claves de «${cn}»`,
52
- sign: 'Firmar como tú',
56
+ sign: 'Entrar a las apps como tú',
53
57
  store: 'Guardar tus datos',
54
58
  read: 'Leer tus datos',
55
- admin: 'Administrar el perfil',
59
+ admin: 'Conectar y quitar dispositivos',
56
60
  passwords: 'Pedir tus contraseñas',
57
- approve: 'Aprobar pedidos',
58
- sealer: 'Sellar el acta',
59
- unattended: 'Recibir claves sin aprobación'
61
+ approve: 'Autorizar a otros aparatos',
62
+ sealer: 'Admitir aparatos sin esta bóveda',
63
+ unattended: 'Llevarse claves sin pedirte permiso',
64
+ replica: 'Entregar tus claves con la bóveda apagada'
60
65
  },
61
66
  capHint: {
62
67
  secrets: (cn) => `abrir SU cajón y ninguno más: las variables de «${cn}»`,
63
68
  sign: 'usar tu identidad en las apps del ecosistema',
64
69
  store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
65
70
  read: 'ver lo que guardaste',
66
- admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí',
71
+ admin: 'hacerlo desde ese aparato, sin venir aquí. No puede cambiar permisos',
67
72
  passwords: 'el gestor: pedir una credencial por sitio, nunca la lista entera',
68
73
  approve: 'dar el visto bueno cuando otro aparato pide claves privadas',
69
74
  // Sellar solo significa algo en un aparato que HAGA de bóveda (otra máquina con el
70
75
  // daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
71
76
  // lo usa nadie. Se enseña igual, y se dice: verlo desactivado explica el modelo;
72
77
  // esconderlo hacía creer que no existe (dueño, 2026-08-31).
73
- sealer: 'admitir aparatos y cambiar permisos él solo, si esta bóveda no está. Solo sirve en otra BÓVEDA',
74
- unattended: 'llevarse claves privadas SIN preguntarte. Apagado, cada vez que arranque tienes que aprobarlo desde un aparato con «Aprobar»'
78
+ sealer: 'admitir aparatos y cambiar permisos él solo cuando esta bóveda no está. Solo sirve en otra BÓVEDA',
79
+ unattended: 'llevarse claves privadas SIN preguntarte. Apagado, cada vez que arranque tienes que aprobarlo desde un aparato con «Aprobar»',
80
+ // El replicador reparte y no puede nada más: no abre lo que entrega ni cambia nada.
81
+ replica: 'las entrega cerradas, tal como salieron de aquí: no puede abrirlas ni cambiar nada. Solo sirve en otra máquina'
75
82
  },
76
83
  confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
77
84
  capGiven: (n) => `Concedido: ${n}`,
78
85
  capTaken: (n) => `Quitado: ${n}`,
79
86
  applyingCaps: 'Aplicando…',
80
- loadingMembers: 'Cargando el acta…',
87
+ loadingMembers: 'Cargando los dispositivos…',
81
88
  helpCaps: ['↑↓', 'Enter marcar', 'G guardar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
82
89
  // Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
83
90
  // el aparato, no en la máquina donde vive la bóveda.
@@ -152,7 +159,7 @@ const es = {
152
159
  noDevices: ' (sin dispositivos enrolados — pulsa P para emparejar uno)',
153
160
  noLabel: '(sin etiqueta)',
154
161
  // Está en el acta y no puede entrar: o le retiraron el certificado, o se le venció.
155
- deviceNoAccess: 'SIN ACCESO — está en el acta pero no puede entrar',
162
+ deviceNoAccess: 'SIN ACCESO — está en el perfil pero no puede entrar',
156
163
  deviceDebt: (n) => `no abre ${n}`,
157
164
  thisVault: 'esta bóveda (manda ella)',
158
165
  cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
@@ -165,7 +172,7 @@ const es = {
165
172
  noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
166
173
  rejecting: 'Rechazando…',
167
174
  deviceRejected: 'Dispositivo rechazado',
168
- revokeConfirm: (id) => `¿Quitar ${id} del perfil? Sale del acta y se le retiran los certificados ya. Lo que tenga guardado se le borra cuando se conecte: si está apagado, sigue ahí hasta entonces.`,
175
+ revokeConfirm: (id) => `¿Quitar ${id} del perfil? Pierde el acceso ahora mismo. Lo que tenga guardado se le borra cuando se conecte: si está apagado, sigue ahí hasta entonces.`,
169
176
  revoking: 'Revocando…',
170
177
  deviceRevoked: (id) => `Revocado ${id}`,
171
178
  approveLabel: (id) => `Código que MUESTRA el dispositivo ${id}`,
@@ -213,11 +220,15 @@ const es = {
213
220
  },
214
221
  loadNothing: 'No se cargó nada:',
215
222
  varPublic: 'pública',
216
- newVarPublicAsk: '¿Que su valor se pueda VER desde la consola remota? (las demás no salen de esta máquina)',
217
- makePublicConfirm: '¿Dejar que su valor se vea desde la consola remota?',
218
- changingVisibility: 'Cambiando visibilidad…',
219
- nowPublic: 'Ahora su valor se puede ver desde la consola remota',
220
- nowPrivate: 'Ahora su valor no sale de esta máquina',
223
+ // LA PREGUNTA ES LA PREGUNTA, sin explicar la consecuencia (dueño, 2026-09-02:
224
+ // «debe decir: ¿la variable es privada? y punto»). Antes decía «¿que su valor se pueda
225
+ // VER desde la consola remota?», que además ya era FALSO: desde el 2026-09-02 las dos se
226
+ // guardan cifradas igual y `pública` solo dice que se entrega sin pedirte permiso.
227
+ newVarPrivateAsk: '¿Es privada?',
228
+ makePublicConfirm: '¿Hacerla pública?',
229
+ changingVisibility: 'Cambiando…',
230
+ nowPublic: 'Pública: se entrega sin pedirte permiso',
231
+ nowPrivate: 'Privada: para entregarla te pide permiso',
221
232
 
222
233
  // variables de UN aparato (Dispositivos → tecla e). Las del scope las comparten todos
223
234
  // los aparatos que sirven ese namespace; estas las lee solo él, y le ganan.
@@ -332,11 +343,11 @@ const es = {
332
343
  // El código NO es la contraseña de la bóveda: son los seis dígitos que el aparato
333
344
  // enseña en su pantalla. Si no coincide, la bóveda no firma nada y el aparato sigue
334
345
  // esperando ahí, así que se puede volver a intentar con A.
335
- errWrongCode: 'El código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Míralo otra vez y pulsa A.',
346
+ errWrongCode: 'El código no coincide con el que muestra el dispositivo: no ha entrado nadie. Míralo otra vez y pulsa A.',
336
347
  errProfileLocked: 'Bóveda bloqueada: ábrela con su contraseña',
337
348
  errWrongPassword: (n) => `Contraseña incorrecta${n ? ` — van ${n} intentos fallidos` : ''}`,
338
349
  errTooManyTries: (s) => `Demasiados intentos: espera ${s || '?'} s antes de volver a probar`,
339
- errMasterWithMembers: 'Esta bóveda es el Master de esta cuenta y hay otros dispositivos: pásale primero el Master a uno que esté conectado. Si la borras así, se quedan con su llave y sin nadie que pueda volver a firmar el acta.'
350
+ errMasterWithMembers: 'Esta bóveda es la que manda en esta cuenta y hay otros dispositivos: pásale primero el mando a uno que esté conectado. Si la borras así, se quedan con su llave y sin nadie que pueda volver a darles permisos.'
340
351
  }
341
352
 
342
353
  // ---------------------------------- inglés ----------------------------------
@@ -362,35 +373,37 @@ const en = {
362
373
  capsNoMember: ' (this device is no longer in the record)',
363
374
  capsApplyHint: 'Enter ticks or unticks · G saves · Esc leaves',
364
375
  capsPending: (n) => `${n} unsaved change(s) (*) · G to save them in ONE record · Esc to discard`,
365
- capsSaved: (n) => `${n} change(s) saved in a single record`,
376
+ capsSaved: (n) => `${n} change(s) saved in one go`,
366
377
  capsNothing: 'You have not changed anything',
367
378
  capsDiscard: 'Discard the unsaved changes?',
368
379
  capName: {
369
380
  secrets: (cn) => `Read the «${cn}» keys`,
370
- sign: 'Sign as you',
381
+ sign: 'Sign in to apps as you',
371
382
  store: 'Save your data',
372
383
  read: 'Read your data',
373
- admin: 'Manage the profile',
384
+ admin: 'Connect and remove devices',
374
385
  passwords: 'Ask for your passwords',
375
- approve: 'Approve requests',
376
- sealer: 'Seal the record',
377
- unattended: 'Take keys without approval'
386
+ approve: 'Authorise other devices',
387
+ sealer: 'Admit devices without this vault',
388
+ unattended: 'Take keys without asking you',
389
+ replica: 'Hand out your keys while the vault is off'
378
390
  },
379
391
  capHint: {
380
392
  sign: 'use your identity across the ecosystem apps',
381
393
  store: 'write to your vault (profile, content, sensitive data)',
382
394
  read: 'see what you saved',
383
- admin: 'connect and remove devices from that device, without coming here',
395
+ admin: 'do it from that device, without coming here. It cannot change permissions',
384
396
  passwords: 'the manager: ask for one credential per site, never the whole list',
385
397
  approve: 'give the go-ahead when another device asks for private keys',
386
- sealer: 'admit devices and change permissions on its own if this vault is gone. Only useful on another VAULT',
387
- unattended: 'take private keys WITHOUT asking you. Off, you must approve it from a device with «Approve» every time it starts'
398
+ sealer: 'admit devices and change permissions on its own when this vault is away. Only useful on another VAULT',
399
+ unattended: 'take private keys WITHOUT asking you. Off, you must approve it from a device with «Approve» every time it starts',
400
+ replica: 'it hands them over sealed, exactly as they left here: it cannot open them or change anything. Only useful on another machine'
388
401
  },
389
402
  confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
390
403
  capGiven: (n) => `Granted: ${n}`,
391
404
  capTaken: (n) => `Removed: ${n}`,
392
405
  applyingCaps: 'Applying…',
393
- loadingMembers: 'Loading the record…',
406
+ loadingMembers: 'Loading the devices…',
394
407
  helpCaps: ['↑↓', 'Enter tick', 'G save', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
395
408
  loadingProfile: 'Loading profile…',
396
409
  noProfile: ' (this vault has no profile yet)',
@@ -474,7 +487,7 @@ const en = {
474
487
  noPendingToReject: 'No device is waiting to be rejected',
475
488
  rejecting: 'Rejecting…',
476
489
  deviceRejected: 'Device rejected',
477
- revokeConfirm: (id) => `Remove ${id} from the profile? It leaves the record and its certificates are withdrawn right away. What it has stored is erased when it connects: if it is switched off, it stays there until then.`,
490
+ revokeConfirm: (id) => `Remove ${id} from the profile? It loses access right away. What it has stored is erased when it connects: if it is switched off, it stays there until then.`,
478
491
  revoking: 'Revoking…',
479
492
  deviceRevoked: (id) => `Revoked ${id}`,
480
493
  approveLabel: (id) => `Code SHOWN by device ${id}`,
@@ -519,11 +532,11 @@ const en = {
519
532
  },
520
533
  loadNothing: 'Nothing was loaded:',
521
534
  varPublic: 'public',
522
- newVarPublicAsk: 'Let its value be SEEN from the remote console? (the rest never leave this machine)',
523
- makePublicConfirm: 'Let its value be seen from the remote console?',
524
- changingVisibility: 'Changing visibility…',
525
- nowPublic: 'Its value can now be seen from the remote console',
526
- nowPrivate: 'Its value no longer leaves this machine',
535
+ newVarPrivateAsk: 'Is it private?',
536
+ makePublicConfirm: 'Make it public?',
537
+ changingVisibility: 'Changing…',
538
+ nowPublic: 'Public: handed over without asking you',
539
+ nowPrivate: 'Private: handing it over asks you first',
527
540
 
528
541
  // variables de UN aparato (Dispositivos → tecla e)
529
542
  titleDevVars: 'Device variables',
@@ -618,7 +631,7 @@ const en = {
618
631
  errNotApplied: 'The daemon did not apply the change (check the service logs).',
619
632
  errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
620
633
  errPairFailed: 'The daemon did not start the pairing.',
621
- errWrongCode: 'The code does not match the one shown by the device: no certificate was issued. Check it and press A again.',
634
+ errWrongCode: 'The code does not match the one shown by the device: nobody got in. Check it and press A again.',
622
635
  errProfileLocked: 'Vault locked: open it with its password',
623
636
  errWrongPassword: (n) => `Wrong password${n ? ` — ${n} failed attempts so far` : ''}`,
624
637
  errTooManyTries: (s) => `Too many attempts: wait ${s || '?'} s before trying again`,
package/src/vault.js CHANGED
@@ -1161,6 +1161,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1161
1161
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
1162
1162
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
1163
1163
  if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
1164
+ if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
1164
1165
  } catch (e) {
1165
1166
  reply(from, { type: MSG.ERROR, error: e.message })
1166
1167
  }
@@ -1355,7 +1356,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1355
1356
  * ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
1356
1357
  * lo de un aparato no cancele el aviso pendiente de todo su namespace.
1357
1358
  */
1358
- function scheduleNotice (ns) { schedule(ns, () => notifyNsChange(ns)) }
1359
+ function scheduleNotice (ns) {
1360
+ schedule(ns, async () => {
1361
+ await notifyNsChange(ns)
1362
+ // Y a los replicadores el sobre nuevo. Sin esto repartirían el valor de ayer: el
1363
+ // aviso de cambio le dice al servicio que se reinicie, y al reiniciar volvería a
1364
+ // pedir — y si la bóveda está apagada, el replicador le daría lo viejo.
1365
+ await pushToReplicas(`secret:${ns}`)
1366
+ })
1367
+ }
1359
1368
  function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
1360
1369
 
1361
1370
  function schedule (key, fn) {
@@ -1432,6 +1441,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1432
1441
  for (const d of issued || []) avisar(d.sub)
1433
1442
  log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
1434
1443
  } catch (e) { log('[vault] could not notify members of the change:', e.message) }
1444
+ // Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
1445
+ // quedarían con el acta de ayer y repartiendo sobres de un aparato ya revocado.
1446
+ await pushToReplicas(ev)
1447
+ }
1448
+
1449
+ /**
1450
+ * EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Un replicador dice por dónde va en
1451
+ * cuanto se conecta, y si va por detrás se le empuja.
1452
+ *
1453
+ * Sin esto, la bóveda solo empujaba al cambiar algo: un replicador que se enciende
1454
+ * después se quedaba vacío hasta el siguiente cambio, que puede no llegar en semanas.
1455
+ * Y el empujón de un enrolamiento se manda a un aparato que todavía no está conectado.
1456
+ * Lo destapó el smoke y era un hueco del diseño, no del código.
1457
+ *
1458
+ * El acuse va FIRMADO por el replicador y se comprueba contra el acta: sin eso, cualquiera
1459
+ * podría pedirle a la bóveda que reparta el acta y los sobres mandando un acuse inventado.
1460
+ */
1461
+ async function handleReplicaAck (from, p) {
1462
+ const body = p?.body
1463
+ if (!body || body.op !== 'replica.ack') return
1464
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1465
+ if (!record) return
1466
+ // LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
1467
+ // identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
1468
+ // el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
1469
+ const quien = body.publickey
1470
+ if (typeof quien !== 'string' || !Acta.memberCan(record, quien, 'replica')) {
1471
+ return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
1472
+ }
1473
+ if (!(await verifyDeviceSig({ publickey: quien, data: body, signature: p.signature }))) {
1474
+ return log('[vault] a replica ack does not check out against its own key: ignored')
1475
+ }
1476
+ if (typeof body.seq === 'number' && body.seq >= record.seq) return
1477
+ log(`[vault] a replica is at #${body.seq} and we are at #${record.seq}: catching it up`)
1478
+ await pushToReplicas('catch-up')
1479
+ }
1480
+
1481
+ /**
1482
+ * EMPUJAR A LOS REPLICADORES lo que tienen que poder repartir: el acta y los sobres.
1483
+ *
1484
+ * Se manda TODO firmado de antes —el acta la selló la maestra, cada sobre lo firmó la
1485
+ * llave de sellado— así que un replicador no puede falsificar nada de lo que le damos.
1486
+ * Y va cerrado a su destinatario, no a él: reparte sobres que no puede abrir. Es lo que
1487
+ * permite ponerlo en una máquina en la que no confías (`docs/replicas.md` §8.bis).
1488
+ *
1489
+ * Se empuja después de cada cambio, y no se espera acuse: si un replicador está apagado
1490
+ * se queda atrás y lo alcanza el siguiente empujón. Un replicador atrasado no es un
1491
+ * peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
1492
+ * disponibilidad, que es exactamente lo que un replicador arriesga.
1493
+ */
1494
+ async function pushToReplicas (motivo = 'change') {
1495
+ try {
1496
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1497
+ if (!record) return
1498
+ const replicas = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'replica'))
1499
+ if (!replicas.length) return
1500
+
1501
+ // UN SOBRE POR (CAJÓN, APARATO), porque `bundleFor` lo talla a quien lo pide: lleva
1502
+ // solo las envolturas de ESE aparato. Mandar uno genérico no serviría de nada.
1503
+ const bundles = {}
1504
+ for (const m of record.members || []) {
1505
+ if (!m.cn) continue
1506
+ try { bundles[`${m.cn} ${m.pub}`] = secrets.bundleFor(m.cn, m.pub) } catch (_) {}
1507
+ }
1508
+ const body = { op: 'replica.push', seq: record.seq, acta: record, bundles, ts: Date.now() }
1509
+ const seal = await signSeal(body)
1510
+ if (!seal) return log('[vault] cannot push to the replicas: the record names no sealing key of ours')
1511
+ let n = 0
1512
+ for (const r of replicas) {
1513
+ try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
1514
+ catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
1515
+ }
1516
+ log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${motivo}`)
1517
+ } catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
1435
1518
  }
1436
1519
 
1437
1520
  /**
@@ -2291,7 +2374,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2291
2374
  // ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
2292
2375
  // máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
2293
2376
  if (adminKey) await migrarRecuperacionALaFrase(adminKey)
2294
- const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
2377
+ const out = { drawers: 0, wrapped: 0, sealed: 0, dropped: 0, sinLlave: [], failed: [] }
2295
2378
  for (const owner of secrets.owners?.() || []) {
2296
2379
  const before = new Set(secrets.recipientsIn(owner))
2297
2380
  // POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
@@ -2303,6 +2386,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2303
2386
  const after = new Set(secrets.recipientsIn(owner))
2304
2387
  out.drawers++
2305
2388
  out.wrapped += r?.wrapped || 0
2389
+ out.sealed += r?.sealed || 0
2306
2390
  out.dropped += [...before].filter((p) => !after.has(p)).length
2307
2391
  for (const p of r?.sinLlave || []) out.sinLlave.push(p)
2308
2392
  } catch (e) {
@@ -2310,7 +2394,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2310
2394
  out.failed.push({ owner, error: e.message })
2311
2395
  }
2312
2396
  }
2313
- if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
2397
+ if (out.dropped || out.sealed) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped, sealed: out.sealed })
2314
2398
  if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
2315
2399
  return out
2316
2400
  }
@@ -2603,6 +2687,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2603
2687
  if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
2604
2688
  return { locked: r?.locked !== false }
2605
2689
  },
2690
+ // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
2691
+ // de que justo acabe de cambiar algo.
2692
+ pushToReplicas,
2606
2693
  startPairing: desk.startPairing,
2607
2694
  stopPairing: desk.stopPairing,
2608
2695
  listPending: desk.listPending,