@dotrino/vaultd 0.12.0 → 0.14.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 +74 -5
- package/src/daemon.js +53 -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 +204 -32
- package/src/vaultControl.js +8 -8
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('
|
|
46
|
-
console.error('
|
|
47
|
-
console.error('
|
|
48
|
-
console.error('DOTRINO_VAULT_DIR
|
|
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]
|
|
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
|
|
|
@@ -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]
|
|
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
|
}
|
|
@@ -154,6 +154,16 @@ export async function runDaemon () {
|
|
|
154
154
|
const secretsListFile = path.join(dir, 'secrets-list.json')
|
|
155
155
|
const profileReqFile = path.join(dir, 'profile-request.json')
|
|
156
156
|
const dumpReqFile = path.join(dir, 'dump-request.json')
|
|
157
|
+
const meReqFile = path.join(dir, 'me-request.json')
|
|
158
|
+
const meFile = path.join(dir, 'me.json')
|
|
159
|
+
|
|
160
|
+
/** Resumen de la foto de perfil: qué es y cuánto pesa, nunca los bytes. */
|
|
161
|
+
function avatarInfo (avatar) {
|
|
162
|
+
if (typeof avatar !== 'string' || !avatar) return null
|
|
163
|
+
const m = /^data:([^;,]+)?(?:;base64)?,(.*)$/s.exec(avatar)
|
|
164
|
+
if (!m) return { type: null, bytes: avatar.length }
|
|
165
|
+
return { type: m[1] || 'desconocido', bytes: Math.floor(m[2].length * 3 / 4) }
|
|
166
|
+
}
|
|
157
167
|
|
|
158
168
|
/**
|
|
159
169
|
* Órdenes de perfil (crear/renombrar/borrar/activar) y del candado
|
|
@@ -175,7 +185,7 @@ export async function runDaemon () {
|
|
|
175
185
|
case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
|
|
176
186
|
case 'password-set': { await mgr.profiles.setPassword(ref(), req.password); return { done: 'contraseña guardada' } }
|
|
177
187
|
case 'password-rm': { mgr.profiles.removePassword(ref()); return { done: 'contraseña quitada' } }
|
|
178
|
-
default: throw new Error('
|
|
188
|
+
default: throw new Error('unknown profile operation: ' + req.op)
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
191
|
|
|
@@ -186,7 +196,7 @@ export async function runDaemon () {
|
|
|
186
196
|
try {
|
|
187
197
|
const vault = targetOf(appr)
|
|
188
198
|
const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile); console.log('[vault] aprobado %s', r.deviceId)
|
|
189
|
-
} catch (e) { console.error('[vault]
|
|
199
|
+
} catch (e) { console.error('[vault] approval failed:', e.message) }
|
|
190
200
|
rm(approveReqFile)
|
|
191
201
|
}
|
|
192
202
|
const rej = readJsonSafe(rejectReqFile)
|
|
@@ -200,13 +210,13 @@ export async function runDaemon () {
|
|
|
200
210
|
rm(path.join(dir, 'caps-request.json'))
|
|
201
211
|
try {
|
|
202
212
|
await targetOf(capsReq)?.setCaps(capsReq.pub, capsReq.caps)
|
|
203
|
-
console.log('[vault]
|
|
204
|
-
} catch (e) { console.error('[vault]
|
|
213
|
+
console.log('[vault] permissions updated: %s', capsReq.caps.join(', ') || '(ninguno)')
|
|
214
|
+
} catch (e) { console.error('[vault] could not change permissions:', e.message) }
|
|
205
215
|
}
|
|
206
216
|
const req = readJsonSafe(revokeReqFile)
|
|
207
217
|
if (req?.nonce) {
|
|
208
|
-
try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault]
|
|
209
|
-
catch (e) { console.error('[vault]
|
|
218
|
+
try { await targetOf(req)?.revokeDevice(req.nonce); console.log('[vault] revoked nonce=%s', req.nonce) }
|
|
219
|
+
catch (e) { console.error('[vault] revocation failed:', e.message) }
|
|
210
220
|
rm(revokeReqFile)
|
|
211
221
|
}
|
|
212
222
|
// Secretos de servicios: `secret set/rm` del CLI. El archivo con el valor
|
|
@@ -216,9 +226,9 @@ export async function runDaemon () {
|
|
|
216
226
|
rm(secretReqFile)
|
|
217
227
|
try {
|
|
218
228
|
const vault = targetOf(sec)
|
|
219
|
-
if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault]
|
|
220
|
-
else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault]
|
|
221
|
-
} catch (e) { console.error('[vault]
|
|
229
|
+
if (sec.op === 'set') { vault.setSecret(sec.ns, sec.key, sec.value); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
|
|
230
|
+
else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
231
|
+
} catch (e) { console.error('[vault] secret failed:', e.message) }
|
|
222
232
|
}
|
|
223
233
|
// Perfiles / candado.
|
|
224
234
|
const preq = readJsonSafe(profileReqFile)
|
|
@@ -228,7 +238,7 @@ export async function runDaemon () {
|
|
|
228
238
|
try { extra = await handleProfileRequest(preq) }
|
|
229
239
|
// `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
|
|
230
240
|
// 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]
|
|
241
|
+
catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] profile: %s', e.message) }
|
|
232
242
|
dumpProfiles(extra)
|
|
233
243
|
} else {
|
|
234
244
|
dumpProfiles()
|
|
@@ -242,8 +252,32 @@ export async function runDaemon () {
|
|
|
242
252
|
writeJson(devFile, { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.listDevices()) })
|
|
243
253
|
// Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
|
|
244
254
|
try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
|
|
255
|
+
|
|
256
|
+
// PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
|
|
257
|
+
// Solo se vuelca cuando se PIDE, no en cada señal: es contenido del usuario y no tiene
|
|
258
|
+
// por qué quedar escrito en un archivo suelto cada vez que alguien mira los miembros.
|
|
259
|
+
// La FOTO no entra en el volcado (son hasta ~90 KB de data-URI que nadie va a leer en
|
|
260
|
+
// una terminal): se resume, y si la quieres, `--foto <archivo>` la escribe donde digas.
|
|
261
|
+
const meReq = readJsonSafe(meReqFile)
|
|
262
|
+
if (meReq) {
|
|
263
|
+
rm(meReqFile)
|
|
264
|
+
try {
|
|
265
|
+
const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
|
|
266
|
+
const { me } = tm.vault.threads.methods.profileGet()
|
|
267
|
+
const { avatar, ...resto } = me || {}
|
|
268
|
+
let guardada = null
|
|
269
|
+
if (meReq.avatarPath && typeof avatar === 'string') {
|
|
270
|
+
const m = /^data:([^;,]+)?(?:;base64)?,(.*)$/s.exec(avatar)
|
|
271
|
+
if (m) {
|
|
272
|
+
fs.writeFileSync(meReq.avatarPath, Buffer.from(m[2], 'base64'), { mode: 0o600 })
|
|
273
|
+
guardada = meReq.avatarPath
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
writeJson(meFile, { v: 1, at: Date.now(), profile: tm.id, me: me ? { ...resto, avatar: avatarInfo(avatar) } : null, avatarGuardada: guardada })
|
|
277
|
+
} catch (e) { console.error('[vault] could not dump the profile:', e.message) }
|
|
278
|
+
}
|
|
245
279
|
} catch (e) {
|
|
246
|
-
console.error('[vault] error
|
|
280
|
+
console.error('[vault] error handling a control signal:', e.message)
|
|
247
281
|
}
|
|
248
282
|
}
|
|
249
283
|
|
|
@@ -272,14 +306,14 @@ export async function runDaemon () {
|
|
|
272
306
|
// atiende cuando el archivo YA parsea; si no, lo recoge el repaso de 2 s.
|
|
273
307
|
if (readJsonSafe(pairReqFile)) await atenderEmparejamiento()
|
|
274
308
|
await atenderPeticiones()
|
|
275
|
-
} catch (e) { console.error('[vault] error
|
|
309
|
+
} catch (e) { console.error('[vault] error serving a request:', e.message) }
|
|
276
310
|
finally { atendiendo = false }
|
|
277
311
|
}
|
|
278
312
|
|
|
279
313
|
try {
|
|
280
314
|
fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) atender() })
|
|
281
315
|
} catch (e) {
|
|
282
|
-
console.error('[vault]
|
|
316
|
+
console.error('[vault] could not watch %s (%s); will be served by polling only', dir, e.message)
|
|
283
317
|
}
|
|
284
318
|
const repaso = setInterval(atender, REPASO_MS)
|
|
285
319
|
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('
|
|
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]
|
|
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)
|
|
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
|
])
|