@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/README.md +534 -188
- package/bin/dotrino-vaultd.js +3 -1
- 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 +41 -35
- package/lib/src/env.js +119 -8
- package/lib/src/index.js +70 -18
- package/lib/src/invite.js +10 -4
- package/lib/src/protocol.js +29 -1
- package/lib/src/sealed.js +1 -1
- package/lib/src/service.js +263 -14
- 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 +20 -20
- 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 +208 -34
- 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
|
|
|
@@ -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]
|
|
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('
|
|
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]
|
|
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]
|
|
204
|
-
} catch (e) { console.error('[vault]
|
|
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]
|
|
209
|
-
catch (e) { console.error('[vault]
|
|
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]
|
|
220
|
-
else if (sec.op === 'rm') { vault.deleteSecret(sec.ns, sec.key); console.log('[vault]
|
|
221
|
-
} catch (e) { console.error('[vault]
|
|
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]
|
|
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
|
|
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
|
|
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]
|
|
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('
|
|
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
|
])
|