@dotrino/vaultd 0.12.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/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
  ])