@dotrino/vaultd 0.97.0 → 0.105.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.js CHANGED
@@ -26,7 +26,7 @@ import { startSealersPublisher } from './sealers.js'
26
26
  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
- import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP } from './secretsStore.js'
29
+ import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
30
30
  // `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
31
31
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
32
32
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
@@ -36,7 +36,9 @@ import { makeSealer } from './sealer.js'
36
36
  import { openSealKeys } from './sealKey.js'
37
37
  import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
38
38
  import { seal } from '../lib/src/sealed.js'
39
+ import { localSocketPath, socketDir } from '../lib/src/localdesk.js'
39
40
  import { dataDir, ensureDir } from './paths.js'
41
+ import net from 'node:net'
40
42
  import { atRestFor, kekFor, migrateFile, encryptText, decryptText } from './atrest.js'
41
43
  import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
42
44
 
@@ -321,15 +323,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
321
323
  * Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
322
324
  * Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
323
325
  */
326
+ /**
327
+ * QUIÉN CONTESTA A QUIÉN. Por defecto el proxio; un remitente LOCAL (el socket de esta
328
+ * máquina) se apunta aquí con su propia forma de contestar.
329
+ *
330
+ * Que haya dos transportes no cambia NADA de quién puede pedir qué: la autorización sale
331
+ * del certificado y del acta, igual por los dos caminos. Alcanzar el socket no autoriza
332
+ * nada — es la misma puerta, con el camino más corto.
333
+ */
334
+ const canales = new Map()
335
+
324
336
  const reply = (to, obj) => {
325
337
  try {
338
+ const enviar = canales.get(to) || ((o) => client.send(to, o))
326
339
  const bytes = Buffer.byteLength(JSON.stringify(obj))
327
340
  if (bytes > MAX_REPLY_BYTES) {
328
341
  log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
329
342
  audit('reply-too-big', { type: obj?.type || null, bytes })
330
- return client.send(to, { type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
343
+ return enviar({ type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
331
344
  }
332
- client.send(to, obj)
345
+ enviar(obj)
333
346
  } catch (e) { log('[vault] could not reply:', e.message) }
334
347
  }
335
348
 
@@ -610,6 +623,69 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
610
623
 
611
624
  // Store de hilos+aperturas (Fase 3): escrituras requieren vault:store; lecturas
612
625
  // aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
626
+ /**
627
+ * LOS DATOS DEL PERFIL (`docs/datos-del-perfil.md`). Tres métodos y ninguno más:
628
+ *
629
+ * · `profilePut` — guarda un dato. Privado = sobre ya sellado; público = en claro.
630
+ * · `profileBundle` — los sobres de ESTE aparato, para que arme su perfil.
631
+ * · `profilePublic` — lo público en claro, que puede pedir cualquiera.
632
+ *
633
+ * Lo que NO se comprueba aquí: el candado. Un sobre sellado no lo puede leer ni fabricar
634
+ * la bóveda, así que guardarlo no es una decisión suya y no necesita su maestra. Ese era
635
+ * el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
636
+ * todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
637
+ */
638
+ const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
639
+
640
+ async function handleProfile (from, p, d) {
641
+ // LO PÚBLICO NO PIDE NADA. Es lo que ve quien pregunta desde fuera y no tiene ninguna
642
+ // llave tuya: pedir un papel para leerlo sería contradecir lo que significa «público».
643
+ // Va firmado, así que quien lo recibe comprueba que es tuyo sin fiarse de nadie.
644
+ if (d.method === 'profilePublic') {
645
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: secrets.profilePublic() })
646
+ }
647
+ // A QUIÉN HAY QUE ENVOLVERLE. Lo pide el aparato ANTES de sellar, porque sellar lo hace
648
+ // él: envolver solo necesita públicas, así que puede hacerlo un navegador sin que
649
+ // ninguna privada ande suelta. Sale de aquí y no de una lista suya para que conceder
650
+ // `firma` a un aparato nuevo baste — sin que nadie tenga que acordarse de nada.
651
+ if (d.method === 'profileRecipients') {
652
+ const members = await recipientsOf(PROFILE_OWNER)
653
+ return reply(from, {
654
+ type: MSG.STORE_RESULT,
655
+ method: d.method,
656
+ result: { recoveryPub: secrets.recoveryPub(), members: members.map((m) => ({ pub: m.pub, encPub: m.encPub })) }
657
+ })
658
+ }
659
+
660
+ const revoked = await revocationSet()
661
+ // `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
662
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
663
+ if (!chk.ok) return denyChain(from, chk, p, 'profile')
664
+ if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
665
+
666
+ try {
667
+ if (d.method === 'profileBundle') {
668
+ const b = secrets.profileBundleFor(chk.device)
669
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: b })
670
+ }
671
+ // profilePut
672
+ const { key, cls, sobre, value } = d.args || {}
673
+ const quien = await deviceIdOf(chk.device).catch(() => null)
674
+ let r
675
+ if (cls === 'public') {
676
+ r = await secrets.profilePutPublic(key, value, { by: quien })
677
+ } else {
678
+ r = await secrets.putSealed(PROFILE_OWNER, key, sobre, { by: quien })
679
+ }
680
+ audit('profile.put', { key, cls: cls || 'private', by: quien, gen: r?.gen ?? null })
681
+ // Y a los demás aparatos, que si no se enteran al arrancar y vuelve la divergencia.
682
+ await notifyMembers('profile', { key, by: quien })
683
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: r })
684
+ } catch (e) {
685
+ return reply(from, { type: MSG.ERROR, error: 'profile: ' + e.message })
686
+ }
687
+ }
688
+
613
689
  async function handleStore (from, p) {
614
690
  const d = p.data
615
691
  // `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
@@ -619,9 +695,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
619
695
  return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
620
696
  }
621
697
  if (!isFresh(d)) return staleReply(from)
622
- // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
623
- // dispositivo enrolado puede seguir firmando, leyendo y guardando contenido;
624
- // lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
698
+ // EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
699
+ // (`docs/datos-del-perfil.md`). Dos diferencias, y las dos son el arreglo:
700
+ //
701
+ // · **No hace falta abrir la bóveda.** Lo que llega es un sobre ya sellado y
702
+ // firmado, que ella no puede leer ni falsificar: aceptarlo no es una decisión
703
+ // suya. El candado estaba ahí porque guardaba el perfil en claro; ya no.
704
+ // · **Lo gatea `firma`, no `guarda`.** Escribir tu identidad es hablar por ti, no
705
+ // guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
706
+ // para lo suyo y no debe poder cambiar quién eres.
707
+ if (PROFILE_METHODS.has(d.method)) return await handleProfile(from, p, d)
708
+ // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
709
+ // (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
710
+ // leyendo y guardando contenido.
625
711
  if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
626
712
  audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
627
713
  return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
@@ -981,7 +1067,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
981
1067
  if (!publicOnly && needsApproval(chk.device, record)) {
982
1068
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
983
1069
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
984
- const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
1070
+ // `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
1071
+ // volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
1072
+ // proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
1073
+ // Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
1074
+ const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, from })
985
1075
  audit('secrets.pending', { device: deviceId, ns, id: pend.id })
986
1076
  log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
987
1077
  const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
@@ -1093,8 +1183,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1093
1183
  if (op === 'deny') {
1094
1184
  audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
1095
1185
  log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
1096
- try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) }
1097
- catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
1186
+ try {
1187
+ const err = { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }
1188
+ const porLocal = pend.from && canales.get(pend.from)
1189
+ if (porLocal) porLocal(err)
1190
+ else client.sendByPubkey(pend.device, err)
1191
+ } catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
1098
1192
  return answer({ op: 'deny.result', id, ok: true })
1099
1193
  }
1100
1194
  if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
@@ -1108,8 +1202,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1108
1202
  // EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
1109
1203
  // «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
1110
1204
  // así que el dueño aprobaba una y otra vez creyendo que el teléfono no llegaba.
1111
- try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) }
1112
- catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
1205
+ // Por el canal por el que preguntó. Si fue el proxio, `sendByPubkey`: así lo recoge al
1206
+ // volver aunque ahora esté apagado. Si fue el socket local, por el socket.
1207
+ try {
1208
+ const porLocal = pend.from && canales.get(pend.from)
1209
+ if (porLocal) porLocal({ type: MSG.SECRETS_RESULT, ...res })
1210
+ else client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res })
1211
+ } catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
1113
1212
  return answer({ op: 'approve.result', id, ok: true })
1114
1213
  }
1115
1214
 
@@ -1144,7 +1243,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1144
1243
  } catch (e) { log('[vault] could not notify approvers:', e.message) }
1145
1244
  }
1146
1245
 
1147
- client.on('message', async (from, payload) => {
1246
+ client.on('message', (from, payload) => onMessage(from, payload))
1247
+
1248
+ // EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
1249
+ // de archivo, un directorio de solo lectura) se dice y se sigue: los servicios usan el
1250
+ // proxio como hasta ahora. Lo que NO se hace es callarlo — un atajo que no está y nadie
1251
+ // lo dice es media hora buscando por qué algo va lento.
1252
+ // El socket se nombra por la LLAVE de esta bóveda: una máquina puede tener varias, y un
1253
+ // servicio solo conoce la maestra que lleva pineada. Así los dos lados calculan la misma
1254
+ // ruta sin mirar ningún índice.
1255
+ if (identity.me?.publickey) {
1256
+ abrirMostradorLocal(localSocketPath((await pubkeyId(identity.me.publickey)).slice(0, 16)))
1257
+ }
1258
+
1259
+ async function onMessage (from, payload) {
1148
1260
  if (!payload || typeof payload !== 'object') return
1149
1261
  try {
1150
1262
  if (payload.type === MSG.HELLO) return desk.handleHello(from, payload)
@@ -1161,10 +1273,64 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1161
1273
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
1162
1274
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
1163
1275
  if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
1276
+ if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
1164
1277
  } catch (e) {
1165
1278
  reply(from, { type: MSG.ERROR, error: e.message })
1166
1279
  }
1167
- })
1280
+ }
1281
+
1282
+ /**
1283
+ * EL MOSTRADOR LOCAL: el mismo protocolo, sin salir de la máquina.
1284
+ *
1285
+ * Un servicio en ESTA máquina hablaba con la bóveda dando la vuelta por
1286
+ * `proxy.dotrino.com`: sale a internet, vuelve, y si el proxio tiene un mal momento el
1287
+ * arranque se retrasa cinco segundos por nada. Absurdo cuando los dos procesos comparten
1288
+ * disco (dueño, 2026-09-03: «estando en la misma máquina debería ser inmediato»).
1289
+ *
1290
+ * Es EL MISMO PROTOCOLO y el mismo enrutador: llega `{type, data, signature, cert}` y se
1291
+ * comprueba igual, contra el certificado y contra el acta. **Alcanzar el socket no
1292
+ * autoriza nada** — es la misma puerta por un camino más corto, no una puerta de atrás.
1293
+ * Por eso no hay un solo `if` de «viene de local» en ningún handler, y no debe haberlo.
1294
+ *
1295
+ * Y no es un puerto: es un socket de archivo, en modo 0600 y bajo el directorio del
1296
+ * usuario. No se alcanza desde otra máquina ni desde otro usuario, así que la promesa
1297
+ * del `docker-compose` —«la bóveda no escucha nada»— sigue siendo cierta donde importa.
1298
+ */
1299
+ function abrirMostradorLocal (socketPath) {
1300
+ let n = 0
1301
+ const server = net.createServer((sock) => {
1302
+ const id = 'local:' + (++n)
1303
+ let buf = ''
1304
+ const enviar = (obj) => { try { sock.write(JSON.stringify(obj) + '\n') } catch (_) {} }
1305
+ canales.set(id, enviar)
1306
+ sock.on('data', (chunk) => {
1307
+ buf += chunk
1308
+ // UN TOPE, y por lo mismo que en las respuestas: sin él, quien se conecte puede
1309
+ // hacer crecer este buffer hasta tumbar la bóveda mandando y sin saltos de línea.
1310
+ if (buf.length > MAX_REPLY_BYTES) { buf = ''; enviar({ type: MSG.ERROR, error: 'request too big' }); return }
1311
+ let i
1312
+ while ((i = buf.indexOf('\n')) >= 0) {
1313
+ const linea = buf.slice(0, i); buf = buf.slice(i + 1)
1314
+ if (!linea.trim()) continue
1315
+ let p = null
1316
+ try { p = JSON.parse(linea) } catch (_) { enviar({ type: MSG.ERROR, error: 'malformed request' }); continue }
1317
+ onMessage(id, p).catch((e) => enviar({ type: MSG.ERROR, error: e.message }))
1318
+ }
1319
+ })
1320
+ const cerrar = () => canales.delete(id)
1321
+ sock.on('close', cerrar)
1322
+ sock.on('error', cerrar)
1323
+ })
1324
+ server.on('error', (e) => log('[vault] the local desk could not open (' + e.message + '): services will use the proxy'))
1325
+ try { fs.mkdirSync(socketDir(), { recursive: true, mode: 0o700 }) } catch (_) {}
1326
+ try { fs.rmSync(socketPath, { force: true }) } catch (_) {}
1327
+ server.listen(socketPath, () => {
1328
+ try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
1329
+ log('[vault] local desk at ' + socketPath + ' (same machine, no round trip)')
1330
+ })
1331
+ server.unref?.()
1332
+ return server
1333
+ }
1168
1334
 
1169
1335
  /**
1170
1336
  * AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
@@ -1355,7 +1521,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1355
1521
  * ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
1356
1522
  * lo de un aparato no cancele el aviso pendiente de todo su namespace.
1357
1523
  */
1358
- function scheduleNotice (ns) { schedule(ns, () => notifyNsChange(ns)) }
1524
+ function scheduleNotice (ns) {
1525
+ schedule(ns, async () => {
1526
+ await notifyNsChange(ns)
1527
+ // Y a los replicadores el sobre nuevo. Sin esto repartirían el valor de ayer: el
1528
+ // aviso de cambio le dice al servicio que se reinicie, y al reiniciar volvería a
1529
+ // pedir — y si la bóveda está apagada, el replicador le daría lo viejo.
1530
+ await pushToReplicas(`secret:${ns}`)
1531
+ })
1532
+ }
1359
1533
  function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
1360
1534
 
1361
1535
  function schedule (key, fn) {
@@ -1432,6 +1606,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1432
1606
  for (const d of issued || []) avisar(d.sub)
1433
1607
  log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
1434
1608
  } catch (e) { log('[vault] could not notify members of the change:', e.message) }
1609
+ // Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
1610
+ // quedarían con el acta de ayer y repartiendo sobres de un aparato ya revocado.
1611
+ await pushToReplicas(ev)
1612
+ }
1613
+
1614
+ /**
1615
+ * EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Un replicador dice por dónde va en
1616
+ * cuanto se conecta, y si va por detrás se le empuja.
1617
+ *
1618
+ * Sin esto, la bóveda solo empujaba al cambiar algo: un replicador que se enciende
1619
+ * después se quedaba vacío hasta el siguiente cambio, que puede no llegar en semanas.
1620
+ * Y el empujón de un enrolamiento se manda a un aparato que todavía no está conectado.
1621
+ * Lo destapó el smoke y era un hueco del diseño, no del código.
1622
+ *
1623
+ * El acuse va FIRMADO por el replicador y se comprueba contra el acta: sin eso, cualquiera
1624
+ * podría pedirle a la bóveda que reparta el acta y los sobres mandando un acuse inventado.
1625
+ */
1626
+ async function handleReplicaAck (from, p) {
1627
+ const body = p?.body
1628
+ if (!body || body.op !== 'replica.ack') return
1629
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1630
+ if (!record) return
1631
+ // LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
1632
+ // identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
1633
+ // el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
1634
+ const quien = body.publickey
1635
+ if (typeof quien !== 'string' || !Acta.memberCan(record, quien, 'replica')) {
1636
+ return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
1637
+ }
1638
+ if (!(await verifyDeviceSig({ publickey: quien, data: body, signature: p.signature }))) {
1639
+ return log('[vault] a replica ack does not check out against its own key: ignored')
1640
+ }
1641
+ if (typeof body.seq === 'number' && body.seq >= record.seq) return
1642
+ log(`[vault] a replica is at #${body.seq} and we are at #${record.seq}: catching it up`)
1643
+ await pushToReplicas('catch-up')
1644
+ }
1645
+
1646
+ /**
1647
+ * EMPUJAR A LOS REPLICADORES lo que tienen que poder repartir: el acta y los sobres.
1648
+ *
1649
+ * Se manda TODO firmado de antes —el acta la selló la maestra, cada sobre lo firmó la
1650
+ * llave de sellado— así que un replicador no puede falsificar nada de lo que le damos.
1651
+ * Y va cerrado a su destinatario, no a él: reparte sobres que no puede abrir. Es lo que
1652
+ * permite ponerlo en una máquina en la que no confías (`docs/replicas.md` §8.bis).
1653
+ *
1654
+ * Se empuja después de cada cambio, y no se espera acuse: si un replicador está apagado
1655
+ * se queda atrás y lo alcanza el siguiente empujón. Un replicador atrasado no es un
1656
+ * peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
1657
+ * disponibilidad, que es exactamente lo que un replicador arriesga.
1658
+ */
1659
+ async function pushToReplicas (motivo = 'change') {
1660
+ try {
1661
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1662
+ if (!record) return
1663
+ const replicas = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'replica'))
1664
+ if (!replicas.length) return
1665
+
1666
+ // UN SOBRE POR (CAJÓN, APARATO), porque `bundleFor` lo talla a quien lo pide: lleva
1667
+ // solo las envolturas de ESE aparato. Mandar uno genérico no serviría de nada.
1668
+ const bundles = {}
1669
+ for (const m of record.members || []) {
1670
+ if (!m.cn) continue
1671
+ try { bundles[`${m.cn} ${m.pub}`] = secrets.bundleFor(m.cn, m.pub) } catch (_) {}
1672
+ }
1673
+ const body = { op: 'replica.push', seq: record.seq, acta: record, bundles, ts: Date.now() }
1674
+ const seal = await signSeal(body)
1675
+ if (!seal) return log('[vault] cannot push to the replicas: the record names no sealing key of ours')
1676
+ let n = 0
1677
+ for (const r of replicas) {
1678
+ try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
1679
+ catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
1680
+ }
1681
+ log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${motivo}`)
1682
+ } catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
1435
1683
  }
1436
1684
 
1437
1685
  /**
@@ -1814,6 +2062,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1814
2062
  // abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
1815
2063
  // para quien administra — puede leerla— además de que se despacha sin aprobación.
1816
2064
  const admins = isPublic ? await adminDevices() : []
2065
+ // EL PERFIL SE LE ENVUELVE A QUIEN PUEDE HABLAR POR TI (`docs/datos-del-perfil.md`).
2066
+ //
2067
+ // Va explícito y antes que nada porque si no cae en la rama de «cajón sin dueño» y
2068
+ // acabaría envuelto solo para quien ADMINISTRA — que no es lo que se decidió: leer y
2069
+ // escribir tu identidad es `firma`, y no todos los que firman administran. Un teléfono
2070
+ // normal firma y no administra, y tiene que ver tu perfil.
2071
+ if (owner === PROFILE_OWNER) {
2072
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
2073
+ const firman = (record?.members || [])
2074
+ .filter((m) => m.encPub && Acta.memberCan(record, m.pub, 'sign'))
2075
+ .map((m) => ({ pub: m.pub, encPub: m.encPub }))
2076
+ return conCoselladores(dedup(firman), co)
2077
+ }
1817
2078
  if (owner.startsWith('ns:')) {
1818
2079
  const owned = await nsMembers(owner.slice(3))
1819
2080
  // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
@@ -2369,6 +2630,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2369
2630
  }
2370
2631
  async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
2371
2632
 
2633
+ /**
2634
+ * RENOMBRAR una variable. Para el servicio que la lee es un cambio de configuración como
2635
+ * cualquier otro —la de antes desaparece y aparece otra—, así que se avisa igual: si no,
2636
+ * seguiría arrancando con el nombre viejo hasta el siguiente reinicio.
2637
+ */
2638
+ async function renameSecret (ns, from, to) {
2639
+ const r = await secrets.rename(`ns:${ns}`, from, to)
2640
+ if (r?.renamed) { audit('secret.rename', { ns, from, to }); scheduleNotice(ns) }
2641
+ return r
2642
+ }
2643
+ async function renameDeviceSecret (pub, from, to) {
2644
+ const r = await secrets.rename(`dev:${pub}`, from, to)
2645
+ if (r?.renamed) {
2646
+ audit('secret.rename', { device: await deviceIdOf(pub).catch(() => null), from, to, scope: 'device' })
2647
+ scheduleDeviceNotice(pub)
2648
+ }
2649
+ return r
2650
+ }
2651
+
2372
2652
  /**
2373
2653
  * CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
2374
2654
  *
@@ -2604,6 +2884,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2604
2884
  if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
2605
2885
  return { locked: r?.locked !== false }
2606
2886
  },
2887
+ // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
2888
+ // de que justo acabe de cambiar algo.
2889
+ pushToReplicas,
2607
2890
  startPairing: desk.startPairing,
2608
2891
  stopPairing: desk.stopPairing,
2609
2892
  listPending: desk.listPending,
@@ -2652,7 +2935,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2652
2935
  // El mostrador que atiende a la consola remota. Se expone para poder probar la
2653
2936
  // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
2654
2937
  vars: varsDesk,
2655
- setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
2938
+ setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
2656
2939
  // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
2657
2940
  // por eso es una operación con nombre propio, no algo que ocurra de refilón al
2658
2941
  // desbloquear: deja `secrets.json.v3.bak` para poder volver.
@@ -513,6 +513,30 @@ export function deleteSecret (ns, key, profile) {
513
513
  })
514
514
  }
515
515
 
516
+ /**
517
+ * Renombra una variable: el mismo sobre con otro nombre. No pide la frase — lo único que
518
+ * hay que rehacer es la firma, y de eso se encarga la llave de sellado.
519
+ *
520
+ * Se comprueban LAS DOS cosas: que la nueva esté y que la vieja ya no. Mirar solo una
521
+ * dejaría pasar una copia a medias, que es peor que un fallo.
522
+ */
523
+ export function renameSecret (ns, key, to, profile) {
524
+ return secretOp({ op: 'rename', ns, key, to }, profile, (out) => {
525
+ if (!has(out.ns[ns], to) || has(out.ns[ns], key)) {
526
+ throw coded('the daemon did not rename the variable (check the service logs)', 'NOT_RENAMED')
527
+ }
528
+ })
529
+ }
530
+
531
+ /** Lo mismo para una variable de UN aparato. */
532
+ export function renameDeviceSecret (pub, key, to, profile) {
533
+ return secretOp({ op: 'dev-rename', pub, key, to }, profile, (out) => {
534
+ if (!has(keysOf(out, pub), to) || has(keysOf(out, pub), key)) {
535
+ throw coded('the daemon did not rename the variable (check the service logs)', 'NOT_RENAMED')
536
+ }
537
+ })
538
+ }
539
+
516
540
  /**
517
541
  * Cambia SOLO quién puede ver el valor: `public` deja que la consola remota lo vea,
518
542
  * `private` lo encierra en esta máquina. No toca el valor (ni hace falta conocerlo).