@dotrino/vaultd 0.52.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.js CHANGED
@@ -19,15 +19,20 @@ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.j
19
19
  import { createAdminDesk } from '../lib/src/admin.js'
20
20
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
21
21
  import { createTransport, masterPubkeyOf } from './transport.js'
22
+ import { assertKeyOwnsDir } from './keyowner.js'
22
23
  import { openStore } from './store.js'
23
24
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
24
25
  import { openSecretsStore, assertVar } from './secretsStore.js'
25
- import { createApprovals } from './approvals.js'
26
+ // `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
27
+ // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
28
+ // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
29
+ // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
30
+ import { createApprovals, PENDING_TTL_MS } from './approvals.js'
26
31
  import { makeSealer } from './sealer.js'
27
32
  import { openSealKeys } from './sealKey.js'
28
33
  import { seal } from '../lib/src/sealed.js'
29
34
  import { dataDir, ensureDir } from './paths.js'
30
- import { atRestFor, machineKey, migrateFile } from './atrest.js'
35
+ import { atRestFor, kekFor, migrateFile } from './atrest.js'
31
36
  import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
32
37
 
33
38
  /**
@@ -56,10 +61,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
56
61
  // máquina (puede leer el mismo material); es subir el listón, no una imposibilidad.
57
62
  // La migración verifica antes de reemplazar: si algo falla, el original queda intacto.
58
63
  try {
59
- const r = migrateFile(path.join(dir, 'identity.json'), machineKey(dir))
64
+ const r = migrateFile(path.join(dir, 'identity.json'), kekFor(dir))
60
65
  if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
61
66
  } catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
62
67
  const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
68
+ // ESTE DIRECTORIO ES DE ESTA LLAVE. Es lo único que hay que proteger cuando varias
69
+ // bóvedas viven en un mismo disco: cada una con el suyo, para que nunca se mezclen.
70
+ assertKeyOwnsDir(dir, identity.me?.publickey || null)
63
71
  if (!identity.me?.publickey) await identity.setMyNickname('')
64
72
  // CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
65
73
  // igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
@@ -168,7 +176,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
168
176
  recipients: (owner) => recipientsOf(owner),
169
177
  // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
170
178
  signer: (body) => signSeal(body),
171
- defaultKey: () => new Uint8Array(machineKey(dir))
179
+ defaultKey: () => new Uint8Array(kekFor(dir))
172
180
  })
173
181
  // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
174
182
  // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
@@ -629,8 +637,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
629
637
  audit('secrets.pending', { device: deviceId, ns, id: pend.id })
630
638
  log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
631
639
  const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
632
- const { signature } = await identity.signData(body)
633
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
640
+ // El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
641
+ // esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
642
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record || null })
634
643
  await notifyApprovers(pend, record)
635
644
  return
636
645
  }
@@ -654,13 +663,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
654
663
  // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
655
664
  // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
656
665
  // de dónde salió lo que abre.
666
+ // El acta va en LOS DOS caminos, también en el v3 heredado: ya no sirve solo para
667
+ // decir de dónde salieron los sobres, sino para saber qué llave podía firmar esta
668
+ // respuesta. Sin ella el agente no puede comprobar quién le contestó.
657
669
  const payload = b.legacy
658
- ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
670
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])), acta: record || null }
659
671
  : { sealed: b, acta: record || null }
660
672
  const enc = await seal({ ek, payload })
661
673
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
662
- const { signature } = await identity.signData(body)
663
- return { body, signature }
674
+ // LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
675
+ // no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
676
+ // misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
677
+ // bloqueado, y algún día desde una réplica que no tiene la maestra ni debe tenerla.
678
+ return { body, seal: await sealOrFail(body) }
679
+ }
680
+
681
+ /**
682
+ * Firma con la llave de sellado, o revienta. No hay repliegue a la maestra: si el acta
683
+ * no nombra una llave de sellado que sea nuestra, esta bóveda no está en condiciones de
684
+ * servir, y decirlo es mejor que servir firmado por quien no toca.
685
+ */
686
+ async function sealOrFail (body) {
687
+ const s = await signSeal(body)
688
+ if (!s) throw new Error('this vault has no sealing key named by the record: it cannot serve')
689
+ return s
664
690
  }
665
691
 
666
692
  /**
@@ -684,8 +710,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
684
710
  const by = await deviceIdOf(chk.device).catch(() => null)
685
711
  const answer = async (body) => {
686
712
  body = { ...body, ts: Date.now() }
687
- const { signature } = await identity.signData(body)
688
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
713
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
689
714
  }
690
715
  if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
691
716
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
@@ -748,11 +773,34 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
748
773
  if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
749
774
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
750
775
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
776
+ if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
751
777
  } catch (e) {
752
778
  reply(from, { type: MSG.ERROR, error: e.message })
753
779
  }
754
780
  })
755
781
 
782
+ /**
783
+ * AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
784
+ * ADOPTA, que es lo que hace que conceder un permiso surta efecto en la otra máquina en
785
+ * vez de esperar a que renueve su cert.
786
+ *
787
+ * No se comprueba quién lo manda y no hace falta: `adoptActa` aplica §2.4.1 —firma,
788
+ * encadenado, `seq` que no baja y desempate— así que un acta ajena o vieja se rechaza
789
+ * sola. Fiarse del remitente sería la comprobación débil; fiarse del acta es la fuerte.
790
+ */
791
+ async function handleAdminEvent (payload) {
792
+ const acta = payload?.acta
793
+ if (!acta || typeof acta !== 'object') return
794
+ try {
795
+ const r = await identity.adoptActa?.(acta)
796
+ if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)
797
+ // Y el RECHAZO también se dice. Callarlo dejaría el peor fallo de todos: se concede
798
+ // un permiso en una máquina, la otra no se entera, y no hay una sola línea que lo
799
+ // explique — que es exactamente lo que costó encontrar esto.
800
+ else log(`[vault] IGNORED the record #${acta.seq} announced by another vault: ${r?.reason || 'no reason given'}`)
801
+ } catch (e) { log('[vault] could not adopt the announced record:', e.message) }
802
+ }
803
+
756
804
  // ----- LA BÓVEDA DE CONTRASEÑAS -----
757
805
  //
758
806
  // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
@@ -961,17 +1009,45 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
961
1009
  try {
962
1010
  const body = { ev, ...info, ts: Date.now() }
963
1011
  const { signature } = await identity.signData(body)
1012
+ // EL ACTA VIAJA CON EL AVISO. Sin esto, un miembro se enteraba de que «algo cambió»
1013
+ // pero no de QUÉ, y no veía el acta nueva hasta renovar su cert — hasta 30 días.
1014
+ // Para un aparato eso era lento; para OTRA BÓVEDA es fatal: se le concede `sella` y
1015
+ // no puede sellar, porque su copia del acta no lo dice todavía.
1016
+ // No es un dato secreto (es pública dentro del perfil y estos son sus miembros), y
1017
+ // quien la recibe la adopta por las reglas de §2.4.1, así que una vieja o ajena no
1018
+ // hace daño: se rechaza sola.
1019
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
964
1020
  const { issued } = await identity.listDelegations()
1021
+ // A QUIÉN SE AVISA: a los MIEMBROS DEL ACTA, no solo a quien esta bóveda enroló.
1022
+ //
1023
+ // Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
1024
+ // eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
1025
+ // `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
1026
+ // acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
1027
+ // tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
1028
+ // quedaban con actas distintas y la del dueño sin enterarse de nada.
1029
+ //
1030
+ // El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
1031
+ // suma porque cubre a quien tiene cert y todavía no aparece ahí.
1032
+ const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
1033
+ const yo = identity.me?.publickey || null
965
1034
  // UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
966
1035
  // MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
967
1036
  // y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
968
1037
  // cuidado que en `notifyNsChange`.
969
1038
  const seen = new Set()
970
- for (const d of issued || []) {
971
- if (!d.sub || seen.has(d.sub)) continue
972
- seen.add(d.sub)
973
- try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
1039
+ const avisar = (pub) => {
1040
+ if (!pub || pub === yo || seen.has(pub)) return
1041
+ seen.add(pub)
1042
+ // El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
1043
+ // bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
1044
+ // de log cuesta días (la última vez, tres).
1045
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }) }
1046
+ catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
974
1047
  }
1048
+ for (const m of miembros) avisar(m.pub)
1049
+ for (const d of issued || []) avisar(d.sub)
1050
+ log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
975
1051
  } catch (e) { log('[vault] could not notify members of the change:', e.message) }
976
1052
  }
977
1053
 
@@ -1150,7 +1226,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1150
1226
  * pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
1151
1227
  * diseño). Prometer una protección que no está puesta es peor que no tenerla.
1152
1228
  */
1153
- const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
1229
+ const adminKeyOr = (adminKey) => adminKey || new Uint8Array(kekFor(dir))
1154
1230
 
1155
1231
  // `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
1156
1232
  // usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
@@ -1231,18 +1307,48 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1231
1307
  const rewrapWaiters = new Set()
1232
1308
  const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
1233
1309
 
1310
+ /**
1311
+ * LAS OTRAS BÓVEDAS —los miembros con el permiso `sella`— entran en TODOS los cajones,
1312
+ * también en los que tienen dueño. Decidido por el dueño el 2026-08-30.
1313
+ *
1314
+ * Y no contradice la regla de agosto —«un cajón con dueño no se envuelve para quien
1315
+ * administra»—, porque un cosellador **no es quien administra**: es un master. Aquella
1316
+ * regla saca a los aparatos de consola (un navegador, un portátil con la sesión puesta),
1317
+ * cuyo compromiso es probable y cuya necesidad es comodidad. Una segunda bóveda existe
1318
+ * justamente para poder REGENERAR los sobres el día que la primera no esté, y regenerar
1319
+ * exige abrir: dejarla fuera la haría inútil para el único desastre que viene a cubrir.
1320
+ *
1321
+ * El precio, que se dice y no se esconde: a partir de aquí son DOS los discos cuya
1322
+ * captura abre ese cajón. Eso es exactamente lo que cuesta sobrevivir a perder uno.
1323
+ */
1324
+ async function cosealerMembers () {
1325
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1326
+ if (!record) return []
1327
+ // Sin `encPub` no hay a dónde envolver: se queda en deuda y se ve como `sinLlave`,
1328
+ // igual que cualquier otro miembro incompleto.
1329
+ return (record.members || []).filter((m) =>
1330
+ m.pub !== master && m.encPub && Acta.memberCan(record, m.pub, 'sealer'))
1331
+ }
1332
+
1333
+ /** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
1334
+ const conCoselladores = (base, co) => {
1335
+ const vistos = new Set(base.map((m) => m.pub))
1336
+ return [...base, ...co.filter((m) => !vistos.has(m.pub))]
1337
+ }
1338
+
1234
1339
  async function recipientsOf (owner) {
1340
+ const co = await cosealerMembers()
1235
1341
  if (owner.startsWith('ns:')) {
1236
1342
  const owned = await nsMembers(owner.slice(3))
1237
1343
  // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
1238
1344
  // administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
1239
- return owned.length ? owned : await adminDevices()
1345
+ return conCoselladores(owned.length ? owned : await adminDevices(), co)
1240
1346
  }
1241
1347
  const pub = owner.slice(owner.indexOf(':') + 1)
1242
1348
  const m = await memberOf(pub)
1243
1349
  // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
1244
- if (m?.cn) return [m]
1245
- return [...(m ? [m] : []), ...await adminDevices()]
1350
+ if (m?.cn) return conCoselladores([m], co)
1351
+ return conCoselladores([...(m ? [m] : []), ...await adminDevices()], co)
1246
1352
  }
1247
1353
 
1248
1354
  /**
@@ -1818,6 +1924,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1818
1924
  isMaster: () => identity.isMaster(),
1819
1925
  setCaps: async (pub, caps) => {
1820
1926
  const r = await identity.setCaps(pub, caps)
1927
+ // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
1928
+ // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
1929
+ // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
1930
+ // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
1931
+ // contenedor, porque la imagen traía la versión de npm y el árbol local otra.
1932
+ try {
1933
+ const quedaron = new Set((await identity.profileMembers()).members.find((m) => m.pub === pub)?.caps || [])
1934
+ const perdidos = caps.filter((c) => !quedaron.has(c))
1935
+ if (perdidos.length) {
1936
+ log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
1937
+ }
1938
+ } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
1821
1939
  audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
1822
1940
  await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
1823
1941
  return r