@dotrino/vaultd 0.11.0 → 0.13.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/daemon.js CHANGED
@@ -42,10 +42,10 @@ function comprobarInstanciaUnica (dir) {
42
42
  const pid = Number(s?.pid)
43
43
  if (!pid || pid === process.pid) return
44
44
  try { process.kill(pid, 0) } catch (_) { return } // no existe: el candado es de un muerto
45
- console.error('Ya hay una bóveda corriendo sobre estos datos (proceso %d).', pid)
46
- console.error(' datos: %s', dir)
47
- console.error('Dos bóvedas sobre el mismo directorio se pisan: párá la otra, o usa')
48
- console.error('DOTRINO_VAULT_DIR para darle a esta un directorio propio.')
45
+ console.error('A vault is already running on this data (process %d).', pid)
46
+ console.error(' data: %s', dir)
47
+ console.error('Two vaults on the same directory step on each other: stop the other one,')
48
+ console.error('or use DOTRINO_VAULT_DIR to give this one its own directory.')
49
49
  process.exit(3)
50
50
  }
51
51
 
@@ -91,7 +91,7 @@ export async function runDaemon () {
91
91
  try {
92
92
  const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
93
93
  return { id, vault: mgr.get(id) }
94
- } catch (e) { console.error('[vault] perfil inválido en la petición:', e.message); return null }
94
+ } catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
95
95
  }
96
96
  const targetOf = (req) => resolveTarget(req)?.vault || null
97
97
 
@@ -130,7 +130,7 @@ export async function runDaemon () {
130
130
  // perfil de esta bóveda tiene que haber nacido para eso (`--adopt`
131
131
  // crea uno vacío), o no habría dónde meterla.
132
132
  const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
133
- const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
133
+ const { qr, expiresInMs } = await vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
134
134
  writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
135
135
  // El token es un secreto efímero: no debe quedar en disco más allá de su
136
136
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
@@ -139,7 +139,7 @@ export async function runDaemon () {
139
139
  const cur = readJsonSafe(pairFile)
140
140
  if (cur?.qr?.token === tok) rm(pairFile)
141
141
  }, expiresInMs + 1000).unref?.()
142
- console.log('[vault] emparejamiento iniciado (válido %d min)', expiresInMs / 60000)
142
+ console.log('[vault] pairing started (valid for %d min)', expiresInMs / 60000)
143
143
  } catch (e) {
144
144
  console.error('[vault] no se pudo iniciar emparejamiento:', e.message)
145
145
  }
@@ -175,7 +175,7 @@ export async function runDaemon () {
175
175
  case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
176
176
  case 'password-set': { await mgr.profiles.setPassword(ref(), req.password); return { done: 'contraseña guardada' } }
177
177
  case 'password-rm': { mgr.profiles.removePassword(ref()); return { done: 'contraseña quitada' } }
178
- default: throw new Error('operación de perfil desconocida: ' + req.op)
178
+ default: throw new Error('unknown profile operation: ' + req.op)
179
179
  }
180
180
  }
181
181
 
@@ -186,7 +186,7 @@ export async function runDaemon () {
186
186
  try {
187
187
  const vault = targetOf(appr)
188
188
  const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile); console.log('[vault] aprobado %s', r.deviceId)
189
- } catch (e) { console.error('[vault] aprobación falló:', e.message) }
189
+ } catch (e) { console.error('[vault] approval failed:', e.message) }
190
190
  rm(approveReqFile)
191
191
  }
192
192
  const rej = readJsonSafe(rejectReqFile)
@@ -200,13 +200,13 @@ export async function runDaemon () {
200
200
  rm(path.join(dir, 'caps-request.json'))
201
201
  try {
202
202
  await targetOf(capsReq)?.setCaps(capsReq.pub, capsReq.caps)
203
- console.log('[vault] permisos actualizados: %s', capsReq.caps.join(', ') || '(ninguno)')
204
- } catch (e) { console.error('[vault] no se pudieron cambiar los permisos:', e.message) }
203
+ console.log('[vault] permissions updated: %s', capsReq.caps.join(', ') || '(ninguno)')
204
+ } catch (e) { console.error('[vault] could not change permissions:', e.message) }
205
205
  }
206
206
  const req = readJsonSafe(revokeReqFile)
207
207
  if (req?.nonce) {
208
- try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault] revocado nonce=%s', req.nonce) }
209
- catch (e) { console.error('[vault] revocación falló:', e.message) }
208
+ try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault] revoked nonce=%s', req.nonce) }
209
+ catch (e) { console.error('[vault] revocation failed:', e.message) }
210
210
  rm(revokeReqFile)
211
211
  }
212
212
  // Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
@@ -216,9 +216,9 @@ export async function runDaemon () {
216
216
  rm(secretReqFile)
217
217
  try {
218
218
  const vault = targetOf(sec)
219
- if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secreto guardado: %s/%s', sec.ns, sec.key) }
220
- else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secreto borrado: %s/%s', sec.ns, sec.key) }
221
- } catch (e) { console.error('[vault] secreto falló:', e.message) }
219
+ if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
220
+ else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
221
+ } catch (e) { console.error('[vault] secret failed:', e.message) }
222
222
  }
223
223
  // Perfiles / candado.
224
224
  const preq = readJsonSafe(profileReqFile)
@@ -228,7 +228,7 @@ export async function runDaemon () {
228
228
  try { extra = await handleProfileRequest(preq) }
229
229
  // `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
230
230
  // que leerse en el idioma de quien lo lee, no en el del daemon).
231
- catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] perfil: %s', e.message) }
231
+ catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] profile: %s', e.message) }
232
232
  dumpProfiles(extra)
233
233
  } else {
234
234
  dumpProfiles()
@@ -243,7 +243,7 @@ export async function runDaemon () {
243
243
  // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
244
244
  try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
245
245
  } catch (e) {
246
- console.error('[vault] error en señal de control:', e.message)
246
+ console.error('[vault] error handling a control signal:', e.message)
247
247
  }
248
248
  }
249
249
 
@@ -272,14 +272,14 @@ export async function runDaemon () {
272
272
  // atiende cuando el archivo YA parsea; si no, lo recoge el repaso de 2 s.
273
273
  if (readJsonSafe(pairReqFile)) await atenderEmparejamiento()
274
274
  await atenderPeticiones()
275
- } catch (e) { console.error('[vault] error atendiendo una petición:', e.message) }
275
+ } catch (e) { console.error('[vault] error serving a request:', e.message) }
276
276
  finally { atendiendo = false }
277
277
  }
278
278
 
279
279
  try {
280
280
  fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) atender() })
281
281
  } catch (e) {
282
- console.error('[vault] no se pudo vigilar %s (%s); se atenderá solo por repaso', dir, e.message)
282
+ console.error('[vault] could not watch %s (%s); will be served by polling only', dir, e.message)
283
283
  }
284
284
  const repaso = setInterval(atender, REPASO_MS)
285
285
  repaso.unref?.()
package/src/manager.js CHANGED
@@ -72,7 +72,7 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
72
72
 
73
73
  const get = (id) => {
74
74
  const v = running.get(id)
75
- if (!v) throw new Error('el perfil no está abierto: ' + id)
75
+ if (!v) throw new Error('profile is not open: ' + id)
76
76
  return v
77
77
  }
78
78
 
@@ -120,7 +120,7 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
120
120
  ])
121
121
  assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
122
122
  } else {
123
- log('[vault] perfil %s no está abierto: se borra sin poder comprobar su acta', id)
123
+ log('[vault] profile %s is not open: deleting without being able to check its record', id)
124
124
  }
125
125
  const res = profiles.remove(id) // valida: no es el único, no está bloqueado
126
126
  try { running.get(id)?.close() } catch (_) {}
package/src/paths.js CHANGED
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Resolución del directorio de datos y escritura de archivos con permisos
3
- * restrictivos (0600). En v1 la clave maestra va en claro dentro de este dir
4
- * (la guarda `@dotrino/identity` en `identity.json`); 0600 evita que sea
5
- * world-readable. v2 añadirá cifrado en reposo con contraseña maestra.
3
+ * restrictivos (0600) **y cifrados en reposo**.
4
+ *
5
+ * `readJson`/`writeJson` aceptan un códec de reposo (`atRestFor(dir)`, ver
6
+ * `atrest.js`): con él, el contenido del archivo NO queda en claro en el disco.
7
+ * Lo usan TODOS los archivos de datos del vault —identidad, árbol de contenido
8
+ * (`vault.json`), hilos y perfil (`threads.json`) y secretos de servicios
9
+ * (`secrets.json`)—, no solo la identidad: el contenido del usuario merece el
10
+ * mismo trato que la maestra. `decrypt` deja pasar el texto en claro, así que
11
+ * una instalación anterior se lee igual y queda cifrada en la primera escritura.
6
12
  */
7
13
  import os from 'node:os'
8
14
  import path from 'node:path'
@@ -33,15 +39,25 @@ export function ensureDir (dir) {
33
39
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
34
40
  }
35
41
 
36
- export function readJson (file, fallback) {
37
- try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch (_) { return fallback }
42
+ /** @param {{decrypt:(t:string)=>string}} [atRest] códec de reposo (`atRestFor(dir)`). */
43
+ export function readJson (file, fallback, atRest) {
44
+ try {
45
+ let text = fs.readFileSync(file, 'utf8')
46
+ if (atRest) text = atRest.decrypt(text)
47
+ return JSON.parse(text)
48
+ } catch (_) { return fallback }
38
49
  }
39
50
 
40
- /** Escritura atómica (tmp + rename) con modo 0600. */
41
- export function writeJson (file, obj) {
51
+ /**
52
+ * Escritura atómica (tmp + rename) con modo 0600. Con `atRest`, el archivo se
53
+ * escribe CIFRADO (nunca toca el disco en claro: se cifra antes del tmp).
54
+ * @param {{encrypt:(t:string)=>string}} [atRest]
55
+ */
56
+ export function writeJson (file, obj, atRest) {
42
57
  ensureDir(path.dirname(file))
58
+ const text = JSON.stringify(obj, null, 2)
43
59
  const tmp = file + '.tmp'
44
- fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 })
60
+ fs.writeFileSync(tmp, atRest ? atRest.encrypt(text) : text, { mode: 0o600 })
45
61
  fs.renameSync(tmp, file)
46
62
  try { fs.chmodSync(file, 0o600) } catch (_) {}
47
63
  }
package/src/profiles.js CHANGED
@@ -28,8 +28,14 @@ const REGISTRY = 'profiles.json'
28
28
  const PWD_ITER = 300000 // mismo coste que el candado del navegador
29
29
  const MAX_NAME = 40
30
30
 
31
- /** Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz. */
32
- const LEGACY_FILES = ['identity.json', 'peers.json', 'vault.json', 'threads.json', 'secrets.json', 'activity.log']
31
+ /**
32
+ * Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
33
+ *
34
+ * `atrest.salt` va en la lista y NO es un detalle: los datos van cifrados con una clave
35
+ * derivada del salt que vive JUNTO a ellos, así que mover los archivos sin el salt los
36
+ * dejaría ilegibles (la clave se derivaría de un salt nuevo). Se mudan juntos.
37
+ */
38
+ const LEGACY_FILES = ['identity.json', 'peers.json', 'vault.json', 'threads.json', 'secrets.json', 'activity.log', 'atrest.salt']
33
39
 
34
40
  const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
35
41
 
@@ -70,7 +76,7 @@ export function openProfiles (root = dataDir()) {
70
76
 
71
77
  function assertExists (id) {
72
78
  const p = find(id)
73
- if (!p) throw new Error('el perfil no existe: ' + id)
79
+ if (!p) throw new Error('profile does not exist: ' + id)
74
80
  return p
75
81
  }
76
82
 
@@ -92,7 +98,7 @@ export function openProfiles (root = dataDir()) {
92
98
  const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
93
99
  if (hits.length === 1) return hits[0].id
94
100
  if (hits.length > 1) throw new Error(`hay ${hits.length} perfiles llamados "${ref}"; usa su id (dotrino-vault profile ls)`)
95
- throw new Error('el perfil no existe: ' + ref)
101
+ throw new Error('profile does not exist: ' + ref)
96
102
  },
97
103
 
98
104
  /**
@@ -162,7 +168,7 @@ export function openProfiles (root = dataDir()) {
162
168
  /** Borra el perfil y TODOS sus datos (incluida su maestra). Irreversible. */
163
169
  remove (id) {
164
170
  const p = assertExists(id)
165
- if (data.profiles.length <= 1) throw new Error('no se puede borrar el único perfil')
171
+ if (data.profiles.length <= 1) throw new Error('cannot delete the only profile')
166
172
  api.assertUnlocked(id)
167
173
  data.profiles = data.profiles.filter((x) => x.id !== id)
168
174
  if (data.current === id) data.current = data.profiles[0].id
@@ -177,7 +183,7 @@ export function openProfiles (root = dataDir()) {
177
183
  isProtected: (id) => !!find(id)?.pwd,
178
184
  isLocked: (id) => { const p = find(id); return !!p?.pwd && !unlocked.has(id) },
179
185
  assertUnlocked (id) {
180
- if (api.isLocked(id)) throw new Error('perfil bloqueado: desbloquéalo con tu contraseña (dotrino-vault unlock)')
186
+ if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
181
187
  },
182
188
 
183
189
  async unlock (id, password) {
@@ -193,7 +199,7 @@ export function openProfiles (root = dataDir()) {
193
199
  if (proof !== p.pwd.verifier) {
194
200
  p.tries = { n: tries.n + 1, at: Date.now() }
195
201
  save()
196
- throw new Error('contraseña incorrecta')
202
+ throw new Error('wrong password')
197
203
  }
198
204
  delete p.tries
199
205
  save()
@@ -207,7 +213,7 @@ export function openProfiles (root = dataDir()) {
207
213
  async setPassword (id, password) {
208
214
  const p = assertExists(id)
209
215
  api.assertUnlocked(id)
210
- if (!password || String(password).length < 4) throw new Error('la contraseña debe tener al menos 4 caracteres')
216
+ if (!password || String(password).length < 4) throw new Error('password must be at least 4 characters')
211
217
  const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
212
218
  p.pwd = { v: 1, salt, iter: PWD_ITER, verifier: await derivePwd(password, salt, PWD_ITER) }
213
219
  delete p.tries
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Store de SECRETOS de servicios (`secrets.json`, 0600, mismo dir 0700 que la
3
- * maestra — mismo dominio de confianza, v1 en claro en reposo igual que
4
- * `identity.json`; el cifrado en reposo con contraseña llega con v2 para todo
5
- * el dir). Organizado por NAMESPACE de servicio (`proxy`, `geo`, `bots`…):
6
- * un cert `vault:secrets:<ns>` solo puede leer SU ns.
3
+ * maestra — mismo dominio de confianza, y **cifrado en reposo** con la misma
4
+ * clave ligada a la máquina que la identidad, ver `atrest.js`: son tokens y
5
+ * llaves de producción, no pueden quedar en claro en el disco). Organizado por
6
+ * NAMESPACE de servicio (`proxy`, `geo`, `bots`…): un cert
7
+ * `vault:secrets:<ns>` solo puede leer SU ns.
7
8
  */
8
9
  import path from 'node:path'
9
10
  import { readJson, writeJson } from './paths.js'
11
+ import { atRestFor } from './atrest.js'
10
12
  import { isValidSecretsNs } from './protocol.js'
11
13
 
12
14
  const SCHEMA_VERSION = 1
@@ -15,15 +17,16 @@ const KEY_RE = /^[A-Z0-9_]{1,64}$/
15
17
 
16
18
  export function openSecretsStore (dir) {
17
19
  const file = path.join(dir, 'secrets.json')
18
- let data = readJson(file, null)
20
+ const atRest = atRestFor(dir)
21
+ let data = readJson(file, null, atRest)
19
22
  if (!data || data.schemaVersion !== SCHEMA_VERSION) {
20
23
  data = { schemaVersion: SCHEMA_VERSION, ns: {} }
21
- writeJson(file, data)
22
24
  }
23
- const save = () => writeJson(file, data)
25
+ writeJson(file, data, atRest) // reescribe al abrir: cifra lo que venía en claro
26
+ const save = () => writeJson(file, data, atRest)
24
27
 
25
28
  const assertNs = (ns) => {
26
- if (!isValidSecretsNs(ns)) throw new Error('namespace inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
29
+ if (!isValidSecretsNs(ns)) throw new Error('invalid namespace (use [a-z0-9-]{1,32}, e.g. "proxy")')
27
30
  }
28
31
 
29
32
  return {
@@ -34,9 +37,9 @@ export function openSecretsStore (dir) {
34
37
  },
35
38
  set (ns, key, value) {
36
39
  assertNs(ns)
37
- if (!KEY_RE.test(String(key || ''))) throw new Error('clave inválida (usa MAYUSCULAS_CON_GUION_BAJO, p.ej. TURN_KEY_ID)')
38
- if (typeof value !== 'string' || !value) throw new Error('el valor debe ser un string no vacío')
39
- if (value.length > MAX_VALUE_LEN) throw new Error(`valor demasiado largo (máx ${MAX_VALUE_LEN})`)
40
+ if (!KEY_RE.test(String(key || ''))) throw new Error('invalid key (use UPPERCASE_WITH_UNDERSCORES, e.g. TURN_KEY_ID)')
41
+ if (typeof value !== 'string' || !value) throw new Error('value must be a non-empty string')
42
+ if (value.length > MAX_VALUE_LEN) throw new Error(`value too long (max ${MAX_VALUE_LEN})`)
40
43
  if (!data.ns[ns]) data.ns[ns] = {}
41
44
  data.ns[ns][key] = value
42
45
  save()
package/src/store.js CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Store del árbol de contenidos del vault (`vault.json`). Versionado con
3
- * `schemaVersion` para que v2 pueda introducir cifrado en reposo sin migración
4
- * dolorosa. NO guarda identidad/dispositivos/certs: de eso se encarga
2
+ * Store del árbol de contenidos del vault (`vault.json`). **Cifrado en reposo**
3
+ * con la misma clave ligada a la máquina que la identidad (`atrest.js`): el
4
+ * contenido del usuario no es menos sensible que la maestra. NO guarda
5
+ * identidad/dispositivos/certs: de eso se encarga
5
6
  * `@dotrino/identity` dentro del mismo dir (keypair, contactos, delegaciones,
6
7
  * revocaciones). Aquí vive solo lo del usuario: el árbol y los settings.
7
8
  */
8
9
  import path from 'node:path'
9
10
  import { readJson, writeJson } from './paths.js'
11
+ import { atRestFor } from './atrest.js'
10
12
 
11
13
  const SCHEMA_VERSION = 1
12
14
 
@@ -28,12 +30,15 @@ function findNode (node, id) {
28
30
 
29
31
  export function openStore (dir) {
30
32
  const file = path.join(dir, 'vault.json')
31
- let data = readJson(file, null)
33
+ const atRest = atRestFor(dir)
34
+ let data = readJson(file, null, atRest)
32
35
  if (!data || data.schemaVersion !== SCHEMA_VERSION) {
33
36
  data = { schemaVersion: SCHEMA_VERSION, tree: newTree(), settings: {} }
34
- writeJson(file, data)
35
37
  }
36
- const save = () => writeJson(file, data)
38
+ // Se reescribe SIEMPRE al abrir: así un archivo de una instalación anterior
39
+ // (en claro) queda cifrado sin pedirle nada al usuario.
40
+ writeJson(file, data, atRest)
41
+ const save = () => writeJson(file, data, atRest)
37
42
 
38
43
  return {
39
44
  get raw () { return data },
@@ -41,7 +46,7 @@ export function openStore (dir) {
41
46
  getNode (id) { return findNode(data.tree, id || 'root') },
42
47
  addNode (parentId, node) {
43
48
  const parent = findNode(data.tree, parentId || 'root')
44
- if (!parent) throw new Error('nodo padre no encontrado: ' + parentId)
49
+ if (!parent) throw new Error('parent node not found: ' + parentId)
45
50
  if (!Array.isArray(parent.children)) parent.children = []
46
51
  parent.children.push(node)
47
52
  save()
@@ -9,19 +9,32 @@
9
9
  *
10
10
  * File-backed (`threads.json`), síncrono y simple (sin cuota/IndexedDB). Es el
11
11
  * backend autoritativo; el navegador usa su IndexedDB como caché y sincroniza.
12
+ * **Cifrado en reposo** con la clave ligada a la máquina (`atrest.js`): aquí
13
+ * vive el contenido de las apps y el perfil del usuario, que es exactamente lo
14
+ * que el ecosistema promete que no queda en claro en ningún disco.
12
15
  */
13
16
  import path from 'node:path'
14
17
  import { readJson, writeJson } from './paths.js'
18
+ import { atRestFor } from './atrest.js'
15
19
 
16
20
  const MAX_PER_THREAD = 1000
17
21
 
22
+ // DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
23
+ // llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
24
+ // notas, un documento corto) y ridículos para un abuso.
25
+ const MAX_SECURE_ITEMS = 2000
26
+ const MAX_SECURE_BLOB = 64 * 1024 // por campo sellado (meta y valor)
27
+
18
28
  export function openThreadStore (dir) {
19
29
  const file = path.join(dir, 'threads.json')
20
- let data = readJson(file, null)
30
+ const atRest = atRestFor(dir)
31
+ let data = readJson(file, null, atRest)
21
32
  if (!data || typeof data !== 'object') data = { v: 1, threads: {}, opens: {} }
22
33
  if (!data.threads) data.threads = {}
23
34
  if (!data.opens) data.opens = {}
24
- const save = () => writeJson(file, data)
35
+ if (!data.secure) data.secure = {}
36
+ const save = () => writeJson(file, data, atRest)
37
+ save() // reescribe al abrir: cifra lo que venía en claro
25
38
  const trim = (arr) => { if (arr.length > MAX_PER_THREAD) arr.splice(0, arr.length - MAX_PER_THREAD) }
26
39
 
27
40
  const methods = {
@@ -89,16 +102,75 @@ export function openThreadStore (dir) {
89
102
  save(); return { ok: true, updatedAt: data.profile.updatedAt }
90
103
  },
91
104
  profileGet () { return { me: data.profile || null } },
105
+
106
+ // ----- DATOS SENSIBLES del usuario (F4, docs/consola-remota.md §6) -----
107
+ //
108
+ // Contraseñas, notas, documentos: van al contenido del perfil, cifrados con la
109
+ // CEK de la cuenta y accesibles con `vault:store` — el mismo camino que hilos y
110
+ // perfil. NO tocan `secrets.json`: ese es el cajón de los SERVICIOS (proxy, geo),
111
+ // acotado por CN y con clave ligada a la máquina. Mismo nombre coloquial, distinto
112
+ // dueño.
113
+ //
114
+ // La bóveda guarda DOS SOBRES OPACOS por ficha y no abre ninguno:
115
+ // `meta` — lo que hace falta para pintar la lista (nombre, tipo, carpeta)
116
+ // `enc` — el valor en sí, que solo viaja cuando abres la ficha
117
+ // Los sella el dispositivo con la clave de contenido (`identity.sealContent`). Que
118
+ // sean dos y no uno es lo que permite listar sin bajar todas las contraseñas, y que
119
+ // el nombre («Banco») tampoco quede legible aquí.
120
+ //
121
+ // Alcance: esto es el ALMACÉN. Una app de contraseñas con generador y
122
+ // autocompletado es otra cosa y no vive aquí.
123
+ 'secure.list' () {
124
+ return Object.values(data.secure)
125
+ .map(({ enc, ...rest }) => rest) // el valor NO viaja al listar
126
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
127
+ },
128
+ 'secure.get' ({ id }) {
129
+ if (!id || typeof id !== 'string') throw new Error('id required')
130
+ return data.secure[id] || null
131
+ },
132
+ 'secure.put' ({ id, meta, enc }) {
133
+ if (typeof enc !== 'string' || !enc) throw new Error('enc required (sealed value)')
134
+ if (meta != null && typeof meta !== 'string') throw new Error('meta must be a sealed string')
135
+ // Se comprueba el TAMAÑO, nunca el contenido: son sobres cerrados.
136
+ if (enc.length > MAX_SECURE_BLOB || (meta || '').length > MAX_SECURE_BLOB) throw new Error('secure: item too large')
137
+ const prev = id ? data.secure[id] : null
138
+ if (!prev && Object.keys(data.secure).length >= MAX_SECURE_ITEMS) throw new Error('secure: too many items')
139
+ const rec = {
140
+ id: prev?.id || id || crypto.randomUUID(),
141
+ ts: prev?.ts || Date.now(),
142
+ updatedAt: Date.now(),
143
+ meta: meta ?? prev?.meta ?? null,
144
+ enc
145
+ }
146
+ data.secure[rec.id] = rec
147
+ save()
148
+ return { id: rec.id, updatedAt: rec.updatedAt }
149
+ },
150
+ 'secure.del' ({ id }) {
151
+ if (!id || typeof id !== 'string') throw new Error('id required')
152
+ const had = !!data.secure[id]
153
+ delete data.secure[id]
154
+ if (had) save()
155
+ return { removed: had ? 1 : 0 }
156
+ },
157
+
92
158
  getStats () {
93
159
  const threads = {}
94
160
  for (const [k, arr] of Object.entries(data.threads)) threads[k] = { count: arr.length }
95
- return { threadCount: Object.keys(data.threads).length, threads, opensCount: Object.keys(data.opens).length }
161
+ return { threadCount: Object.keys(data.threads).length, threads, opensCount: Object.keys(data.opens).length, secureCount: Object.keys(data.secure).length }
96
162
  }
97
163
  }
98
164
  return { methods, raw: () => data }
99
165
  }
100
166
 
101
- /** Métodos del store que son de SOLO LECTURA (para decidir el scope necesario). */
167
+ /**
168
+ * Métodos del store que son de SOLO LECTURA (para decidir el scope necesario).
169
+ *
170
+ * `secure.list`/`secure.get` NO están aquí a propósito, aunque sean lecturas: los datos
171
+ * sensibles piden `vault:store` (doc §6), que es MÁS estricto que `vault:read`. Un
172
+ * dispositivo al que solo le diste «leer» no lee tus contraseñas.
173
+ */
102
174
  export const STORE_READ_METHODS = new Set([
103
175
  'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet'
104
176
  ])