@dotrino/vaultd 0.137.0 → 0.139.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.137.0",
3
+ "version": "0.139.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -22,10 +22,10 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "0.105.0",
25
+ "@dotrino/identity": "^0.107.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
- "@dotrino/proxy-client": "0.24.0",
28
+ "@dotrino/proxy-client": "0.27.1",
29
29
  "@dotrino/roadmap": "0.3.19",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.2.2",
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import path from 'node:path'
17
17
  import * as core from '@dotrino/store/core'
18
+ import { resealStale } from '@dotrino/identity/content'
18
19
  import { readJson, writeJson } from './paths.js'
19
20
  import { atRestFor } from './atrest.js'
20
21
 
@@ -26,6 +27,11 @@ const MAX_PER_THREAD = core.MAX_PER_THREAD_LIMIT
26
27
  // DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
27
28
  // llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
28
29
  // notas, un documento corto) y ridículos para un abuso.
30
+ // LO QUE SE VUELVE A CERRAR AL ROTAR NO SE BORRA: la versión anterior de cada sobre se
31
+ // guarda como respaldo AL MENOS UN AÑO (política del dueño, 2026-09-30). No se sirve por
32
+ // ningún método; solo está por si volver a cerrar sale mal.
33
+ export const RESEALED_KEEP_MS = 366 * 24 * 60 * 60 * 1000
34
+
29
35
  const MAX_SECURE_ITEMS = 2000
30
36
  const MAX_SECURE_BLOB = 64 * 1024 // por campo sellado (meta y valor)
31
37
 
@@ -203,7 +209,46 @@ export function openThreadStore (dir) {
203
209
  return { threadCount: Object.keys(data.threads).length, threads, opensCount: Object.keys(data.opens).length, secureCount: Object.keys(data.secure).length }
204
210
  }
205
211
  }
206
- return { methods, raw: () => data }
212
+ /**
213
+ * VUELVE A CERRAR CON LA LLAVE VIGENTE todo sobre de la cuenta (`t: 'dotrino-cek'`) de
214
+ * una generación anterior que haya en lo guardado. Lo llama la BÓVEDA al abrirse y
215
+ * después de quitar un aparato —que es cuando la llave rota—; NO está en `methods`, así
216
+ * que ningún aparato puede dispararlo.
217
+ *
218
+ * Lo que sirve la bóveda va así siempre con la generación vigente: un aparato que entra
219
+ * después, que solo recibe esa, abre todo; y la vieja deja de abrir nada porque ya no se
220
+ * sirve nada cerrado con ella (dueño, 2026-09-30). Cada entrada que cambia estrena `ts`,
221
+ * para que los aparatos la bajen en su siguiente sincronización.
222
+ *
223
+ * La versión anterior NO se tira: queda en `resealed` con la fecha, por si algo sale mal
224
+ * (lo pidió el dueño: «podría retenerlos… pero no los envía»), y se guarda AL MENOS un
225
+ * año (`RESEALED_KEEP_MS`). No se sirve por ningún método.
226
+ *
227
+ * @param {{ gen: number, reseal: (env: object) => Promise<object> }} o
228
+ * @returns {Promise<{ changed: number, entries: number }>}
229
+ */
230
+ async function resealStaleEnvelopes ({ gen, reseal }) {
231
+ let changed = 0; let entries = 0
232
+ const now = Date.now()
233
+ if (!data.resealed) data.resealed = []
234
+ // Solo sale lo que ya cumplió su año de respaldo.
235
+ const antes = data.resealed.length
236
+ data.resealed = data.resealed.filter((x) => now - (x.at || 0) < RESEALED_KEEP_MS)
237
+ const podados = antes - data.resealed.length
238
+ for (const [key, arr] of Object.entries(data.threads)) {
239
+ for (let i = 0; i < arr.length; i++) {
240
+ const r = await resealStale(arr[i], { gen, reseal })
241
+ if (!r.changed) continue
242
+ data.resealed.push({ threadKey: key, at: now, gen, before: arr[i] })
243
+ arr[i] = { ...r.value, ts: Math.max(now, (arr[i].ts || 0) + 1) }
244
+ changed += r.changed; entries++
245
+ }
246
+ }
247
+ if (entries || podados) save()
248
+ return { changed, entries }
249
+ }
250
+
251
+ return { methods, raw: () => data, resealStaleEnvelopes }
207
252
  }
208
253
 
209
254
  /**
package/src/vault.js CHANGED
@@ -116,6 +116,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
116
116
 
117
117
  const store = openStore(dir)
118
118
  const threads = openThreadStore(dir)
119
+ /**
120
+ * LO QUE SIRVE LA BÓVEDA VA CON LA LLAVE VIGENTE (dueño, 2026-09-30). Tras una rotación
121
+ * de la llave de la cuenta, los sobres (`t: 'dotrino-cek'`) de generaciones anteriores
122
+ * que haya en el almacén se vuelven a cerrar con la vigente. Se llama al abrir y después
123
+ * de quitar un aparato. Si la bóveda no tiene la llave de contenido, no hay nada que
124
+ * hacer y se dice; si un sobre no abre, se para y se dice — no se deja a medias.
125
+ */
126
+ async function resealStaleThreads (why) {
127
+ const mine = await identity.contentKey?.()
128
+ if (!mine) { log(`[vault] ${why}: no content key on this vault, envelopes not resealed`); return null }
129
+ const r = await threads.resealStaleEnvelopes({
130
+ gen: mine.gen,
131
+ reseal: async (env) => identity.sealContent(await identity.openContent(env))
132
+ })
133
+ if (r.changed) {
134
+ audit('resealed', { envelopes: r.changed, entries: r.entries, gen: mine.gen })
135
+ log(`[vault] ${why}: resealed ${r.changed} envelope(s) in ${r.entries} entr${r.entries === 1 ? 'y' : 'ies'} with content key generation ${mine.gen}`)
136
+ }
137
+ return r
138
+ }
119
139
  const approvals = createApprovals()
120
140
  /**
121
141
  * LO QUE YA SE APROBÓ: comando + carpeta, por una hora desde el último uso.
@@ -1873,7 +1893,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1873
1893
  // desde fuera se ve igual que «nadie ha aprobado todavía»: se espera para siempre
1874
1894
  // sin nada que mirar. Es el mismo cuidado que en `notifyMembers`.
1875
1895
  for (const pub of who) {
1876
- try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }) }
1896
+ // A la app que APRUEBA (`vault`): en un teléfono la llave es de todas sus apps, y sin
1897
+ // decirlo el pedido timbraba a messenger (websocket-proxy ≥ 1.4.0).
1898
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }) }
1877
1899
  catch (e) { log(`[vault] could not ring ${pub.slice(0, 24)}… about ${pend.ns}: ${e.message}`) }
1878
1900
  }
1879
1901
  log(`[vault] ${pend.ns}: rang ${who.length} approver(s) for ${pend.id}`)
@@ -2283,7 +2305,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2283
2305
  // el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
2284
2306
  // sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
2285
2307
  // El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
2286
- try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, acta }, { quiet: true }) }
2308
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, acta }, { quiet: true, app: 'vault' }) }
2287
2309
  catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
2288
2310
  }
2289
2311
  for (const m of miembros) avisar(m.pub)
@@ -3843,6 +3865,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3843
3865
  }
3844
3866
  } catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
3845
3867
  }
3868
+ // Y DESPUÉS, LOS SOBRES A LA LLAVE VIGENTE: la poda de arriba pudo rotarla.
3869
+ if (r?.locked === false) {
3870
+ try { await resealStaleThreads('unlock') } catch (e) { log(`[vault] unlock: could not reseal old envelopes: ${e.message}`) }
3871
+ }
3846
3872
  return { locked: r?.locked !== false }
3847
3873
  },
3848
3874
  // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
@@ -4075,6 +4101,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4075
4101
  const r = await desk.revokeDevice(sub)
4076
4102
  audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
4077
4103
  await refreshWraps('revoked')
4104
+ // Quitar un aparato rota la llave de la cuenta: lo guardado pasa a la nueva.
4105
+ try { await resealStaleThreads('revoke') } catch (e) { log(`[vault] revoke: could not reseal old envelopes: ${e.message}`) }
4078
4106
  await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
4079
4107
  return r
4080
4108
  },