@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/README.md +534 -188
- package/lib/README.md +136 -5
- package/lib/src/admin.js +146 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +38 -6
- package/lib/src/enroll.js +29 -29
- package/lib/src/env.js +119 -8
- package/lib/src/index.js +70 -18
- package/lib/src/protocol.js +29 -1
- package/lib/src/sealed.js +1 -1
- package/lib/src/service.js +238 -13
- package/package.json +7 -4
- package/src/atrest.js +0 -0
- package/src/client.js +64 -6
- package/src/ctl.js +10 -5
- package/src/daemon.js +19 -19
- package/src/manager.js +2 -2
- package/src/paths.js +24 -8
- package/src/profiles.js +14 -8
- package/src/secretsStore.js +14 -11
- package/src/store.js +12 -7
- package/src/threadStore.js +76 -4
- package/src/vault.js +197 -32
- package/src/vaultControl.js +8 -8
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)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
41
|
-
|
|
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,
|
|
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
|
-
/**
|
|
32
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
package/src/secretsStore.js
CHANGED
|
@@ -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,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
38
|
-
if (typeof value !== 'string' || !value) throw new Error('
|
|
39
|
-
if (value.length > MAX_VALUE_LEN) throw new Error(`
|
|
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`).
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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()
|
package/src/threadStore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
])
|