@dotrino/vaultd 0.138.0 → 0.139.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.138.0",
3
+ "version": "0.139.1",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "0.105.0",
25
+ "@dotrino/identity": "^0.107.1",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
28
  "@dotrino/proxy-client": "0.27.1",
@@ -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,51 @@ 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
+ * También MIGRA los sobres de la cuenta anteriores a la marca (`{ gen, iv, ct }` a secas,
224
+ * identity < 0.107): si abren con la llave de la cuenta se vuelven a cerrar marcados; si no,
225
+ * no eran de la cuenta y se cuentan en `skipped` sin tocarlos.
226
+ *
227
+ * La versión anterior NO se tira: queda en `resealed` con la fecha, por si algo sale mal
228
+ * (lo pidió el dueño: «podría retenerlos… pero no los envía»), y se guarda AL MENOS un
229
+ * año (`RESEALED_KEEP_MS`). No se sirve por ningún método.
230
+ *
231
+ * @param {{ gen: number, reseal: (env: object) => Promise<object> }} o
232
+ * @returns {Promise<{ changed: number, entries: number, skipped: number }>}
233
+ */
234
+ async function resealStaleEnvelopes ({ gen, reseal }) {
235
+ let changed = 0; let entries = 0; let skipped = 0
236
+ const now = Date.now()
237
+ if (!data.resealed) data.resealed = []
238
+ // Solo sale lo que ya cumplió su año de respaldo.
239
+ const antes = data.resealed.length
240
+ data.resealed = data.resealed.filter((x) => now - (x.at || 0) < RESEALED_KEEP_MS)
241
+ const podados = antes - data.resealed.length
242
+ for (const [key, arr] of Object.entries(data.threads)) {
243
+ for (let i = 0; i < arr.length; i++) {
244
+ const r = await resealStale(arr[i], { gen, reseal })
245
+ skipped += r.skipped || 0
246
+ if (!r.changed) continue
247
+ data.resealed.push({ threadKey: key, at: now, gen, before: arr[i] })
248
+ arr[i] = { ...r.value, ts: Math.max(now, (arr[i].ts || 0) + 1) }
249
+ changed += r.changed; entries++
250
+ }
251
+ }
252
+ if (entries || podados) save()
253
+ return { changed, entries, skipped }
254
+ }
255
+
256
+ return { methods, raw: () => data, resealStaleEnvelopes }
207
257
  }
208
258
 
209
259
  /**
package/src/vault.js CHANGED
@@ -116,6 +116,27 @@ 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
+ if (r.skipped) log(`[vault] ${why}: ${r.skipped} unmarked envelope(s) that the account key does not open were left as they are`)
138
+ return r
139
+ }
119
140
  const approvals = createApprovals()
120
141
  /**
121
142
  * LO QUE YA SE APROBÓ: comando + carpeta, por una hora desde el último uso.
@@ -3845,6 +3866,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3845
3866
  }
3846
3867
  } catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
3847
3868
  }
3869
+ // Y DESPUÉS, LOS SOBRES A LA LLAVE VIGENTE: la poda de arriba pudo rotarla.
3870
+ if (r?.locked === false) {
3871
+ try { await resealStaleThreads('unlock') } catch (e) { log(`[vault] unlock: could not reseal old envelopes: ${e.message}`) }
3872
+ }
3848
3873
  return { locked: r?.locked !== false }
3849
3874
  },
3850
3875
  // Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
@@ -4077,6 +4102,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4077
4102
  const r = await desk.revokeDevice(sub)
4078
4103
  audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
4079
4104
  await refreshWraps('revoked')
4105
+ // Quitar un aparato rota la llave de la cuenta: lo guardado pasa a la nueva.
4106
+ try { await resealStaleThreads('revoke') } catch (e) { log(`[vault] revoke: could not reseal old envelopes: ${e.message}`) }
4080
4107
  await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
4081
4108
  return r
4082
4109
  },