@dotrino/vaultd 0.87.0 → 0.89.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/lib/src/admin.js +3 -1
- package/lib/src/atrest.js +47 -3
- package/package.json +1 -1
- package/src/ctl.js +65 -27
- package/src/daemon.js +24 -22
- package/src/ipc.js +41 -0
- package/src/node-globals.js +14 -3
- package/src/secretsStore.js +18 -0
- package/src/vault.js +68 -7
- package/src/vaultControl.js +8 -8
package/lib/src/admin.js
CHANGED
|
@@ -204,7 +204,9 @@ export function createAdminDesk ({
|
|
|
204
204
|
const toScope = typeof data.ns === 'string' && !!data.ns
|
|
205
205
|
const toDevice = typeof data.pub === 'string' && !!data.pub
|
|
206
206
|
if (data.op === 'vars') {
|
|
207
|
-
|
|
207
|
+
// QUIÉN PREGUNTA, no solo su nombre: con su llave se le manda la envoltura de
|
|
208
|
+
// cada pública para que pueda abrirla en su casa.
|
|
209
|
+
const result = await vars.list({ by, caller: chk.device })
|
|
208
210
|
audit('admin.vars', { by })
|
|
209
211
|
return { ok: true, result }
|
|
210
212
|
}
|
package/lib/src/atrest.js
CHANGED
|
@@ -14,8 +14,14 @@
|
|
|
14
14
|
* · `secrets.json` secretos de servicios (tokens y llaves de producción)
|
|
15
15
|
* · `service-identity.json` en la máquina del SERVICIO (`@dotrino/vault/env`):
|
|
16
16
|
* lleva la llave privada de su dispositivo, así que va igual de cifrado.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* · `activity.log` la bitácora de auditoría, cifrada LÍNEA A LÍNEA (ver `LINE_FILES`)
|
|
18
|
+
* · `state.json`, `acta.json`, `secret-request.json`… el canal local con la CLI
|
|
19
|
+
* (`src/ipc.js`), por donde pasan la contraseña del perfil y los valores
|
|
20
|
+
* · `transport.json` el par de transporte de `@dotrino/proxy-client`, con su privada
|
|
21
|
+
*
|
|
22
|
+
* La bitácora estuvo fuera hasta 0.89, con el argumento de que «no guarda payloads». No
|
|
23
|
+
* guarda valores, pero sí el mapa de la cuenta —qué aparatos, con qué permisos, contra
|
|
24
|
+
* qué cajones y a qué hora—, que es lo que mira quien elige por dónde entrar.
|
|
19
25
|
*
|
|
20
26
|
* Qué NO resuelve, dicho sin adornos: **no protege contra alguien con acceso a esta misma
|
|
21
27
|
* máquina como tu usuario o como root** — puede leer el mismo material que leemos nosotros.
|
|
@@ -178,15 +184,31 @@ export function migrateFile (file, key) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
/** Los archivos de este directorio que están cifrados por nosotros. */
|
|
187
|
+
/**
|
|
188
|
+
* Archivos que van cifrados **línea a línea**, no de una pieza.
|
|
189
|
+
*
|
|
190
|
+
* La bitácora se ESCRIBE AÑADIENDO. Cifrarla entera obligaría a reescribir el archivo
|
|
191
|
+
* completo en cada anotación, así que cada entrada se cierra sola. Eso la deja fuera del
|
|
192
|
+
* barrido de abajo (una lectura de todo el archivo no descifra: son muchos textos pegados)
|
|
193
|
+
* y la mete en su propio camino al cambiar de proveedor.
|
|
194
|
+
*/
|
|
195
|
+
export const LINE_FILES = Object.freeze(['activity.log'])
|
|
196
|
+
|
|
181
197
|
export function encryptedFilesIn (dir) {
|
|
182
198
|
let names = []
|
|
183
199
|
try { names = fs.readdirSync(dir) } catch (_) { return [] }
|
|
184
200
|
return names.filter((n) => {
|
|
185
201
|
if (n === CONFIG_FILE || n === WRAPPED_FILE || n === SALT_FILE || n === MACHINE_FILE) return false
|
|
202
|
+
if (LINE_FILES.includes(n)) return false
|
|
186
203
|
try { return isEncrypted(fs.readFileSync(path.join(dir, n), 'utf8')) } catch (_) { return false }
|
|
187
204
|
})
|
|
188
205
|
}
|
|
189
206
|
|
|
207
|
+
/** Las bitácoras presentes en `dir`, con sus líneas. `[]` si no hay ninguna. */
|
|
208
|
+
export function lineFilesIn (dir) {
|
|
209
|
+
return LINE_FILES.filter((n) => { try { return fs.statSync(path.join(dir, n)).isFile() } catch (_) { return false } })
|
|
210
|
+
}
|
|
211
|
+
|
|
190
212
|
/**
|
|
191
213
|
* CAMBIAR DE PROVEEDOR: descifra todo con la clave vieja y lo vuelve a cifrar con la nueva.
|
|
192
214
|
*
|
|
@@ -219,6 +241,19 @@ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
|
|
|
219
241
|
throw new KekError('kek-rekey', `cannot decrypt ${n} with the current provider (${oldCfg.provider}): ${e.message}`)
|
|
220
242
|
}
|
|
221
243
|
}
|
|
244
|
+
// Las bitácoras, por líneas. `decryptText` deja pasar el texto en claro, así que una
|
|
245
|
+
// mitad vieja sin cifrar cruza intacta y sale cifrada con la clave nueva.
|
|
246
|
+
const lineFiles = lineFilesIn(dir)
|
|
247
|
+
const plainLines = new Map()
|
|
248
|
+
for (const n of lineFiles) {
|
|
249
|
+
const p = path.join(dir, n)
|
|
250
|
+
try {
|
|
251
|
+
const lines = fs.readFileSync(p, 'utf8').split('\n')
|
|
252
|
+
plainLines.set(n, lines.map((l) => (l ? decryptText(l, oldKey) : l)))
|
|
253
|
+
} catch (e) {
|
|
254
|
+
throw new KekError('kek-rekey', `cannot decrypt ${n} with the current provider (${oldCfg.provider}): ${e.message}`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
222
257
|
|
|
223
258
|
// 2. La clave nueva, todavía sin escribir su envoltorio.
|
|
224
259
|
let newKey; let newBlob = null
|
|
@@ -236,6 +271,15 @@ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
|
|
|
236
271
|
if (decryptText(blob, newKey) !== text) throw new KekError('kek-verify', `re-encryption check failed for ${n}: nothing was written`)
|
|
237
272
|
out.set(n, blob)
|
|
238
273
|
}
|
|
274
|
+
for (const [n, lines] of plainLines) {
|
|
275
|
+
const blobs = lines.map((l) => (l ? encryptText(l, newKey) : l))
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
if (lines[i] && decryptText(blobs[i], newKey) !== lines[i]) {
|
|
278
|
+
throw new KekError('kek-verify', `re-encryption check failed for ${n} (line ${i + 1}): nothing was written`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
out.set(n, blobs.join('\n'))
|
|
282
|
+
}
|
|
239
283
|
|
|
240
284
|
// 4. A disco. Copia de seguridad, temporal, renombrar.
|
|
241
285
|
const backups = []
|
|
@@ -266,6 +310,6 @@ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
|
|
|
266
310
|
|
|
267
311
|
export default {
|
|
268
312
|
machineKey, kekFor, atRestFor, migrateFile, isEncrypted, encryptText, decryptText,
|
|
269
|
-
rekeyDir, encryptedFilesIn, readConfig, writeConfig, probe, KekError
|
|
313
|
+
rekeyDir, encryptedFilesIn, lineFilesIn, LINE_FILES, readConfig, writeConfig, probe, KekError
|
|
270
314
|
}
|
|
271
315
|
export { readConfig, writeConfig, probe, configFromEnv, KekError, CONFIG_FILE, WRAPPED_FILE }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
package/src/ctl.js
CHANGED
|
@@ -25,13 +25,15 @@ import path from 'node:path'
|
|
|
25
25
|
import { execFileSync } from 'node:child_process'
|
|
26
26
|
import tty from 'node:tty'
|
|
27
27
|
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
28
|
-
import { dataDir
|
|
28
|
+
import { dataDir } from './paths.js'
|
|
29
|
+
// EL CANAL LOCAL VA CIFRADO (ver `ipc.js`): por aquí salen la contraseña y los valores.
|
|
30
|
+
import { ipcRead, ipcWrite } from './ipc.js'
|
|
29
31
|
import { assertVar } from './secretsStore.js'
|
|
30
32
|
import { isValidSecretsNs } from './protocol.js'
|
|
31
33
|
import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
|
|
32
34
|
import { qrToString } from './qr.js'
|
|
33
35
|
import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
|
|
34
|
-
import { readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
|
|
36
|
+
import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
|
|
35
37
|
import { VERSION } from './version.js'
|
|
36
38
|
|
|
37
39
|
const dir = dataDir()
|
|
@@ -60,12 +62,7 @@ const withProfile = (obj) => (PROFILE ? { ...obj, profile: PROFILE } : obj)
|
|
|
60
62
|
* escribirlo, así que escrito en el sitio se puede leer a medias — y una petición que no
|
|
61
63
|
* parsea se pierde con todos sus datos.
|
|
62
64
|
*/
|
|
63
|
-
const writeReq = (name, obj) => {
|
|
64
|
-
const dest = path.join(dir, name)
|
|
65
|
-
const tmp = dest + '.tmp'
|
|
66
|
-
fs.writeFileSync(tmp, JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
|
|
67
|
-
fs.renameSync(tmp, dest)
|
|
68
|
-
}
|
|
65
|
+
const writeReq = (name, obj) => ipcWrite(path.join(dir, name), withProfile({ ...obj, at: Date.now() }))
|
|
69
66
|
|
|
70
67
|
const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
|
|
71
68
|
const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el nombre
|
|
@@ -103,7 +100,7 @@ const RESTART_HINT = IN_DOCKER
|
|
|
103
100
|
: 'cierra el vault y vuelve a arrancarlo'
|
|
104
101
|
|
|
105
102
|
function readState () {
|
|
106
|
-
const s =
|
|
103
|
+
const s = ipcRead(stateFile, null)
|
|
107
104
|
if (!s) {
|
|
108
105
|
console.error('El vault no parece haber arrancado todavía (no hay state.json en %s).', dir)
|
|
109
106
|
console.error('Arráncalo: %s', START_HINT)
|
|
@@ -318,7 +315,7 @@ async function cmdPair (args = []) {
|
|
|
318
315
|
let pair = null
|
|
319
316
|
for (let i = 0; i < 50; i++) {
|
|
320
317
|
await sleep(100)
|
|
321
|
-
const p =
|
|
318
|
+
const p = ipcRead(pairFile, null)
|
|
322
319
|
assertOpen(p) // el candado se contesta por el mismo archivo, para no dejar esperando
|
|
323
320
|
if (p?.expiresAt > Date.now()) { pair = p; break }
|
|
324
321
|
}
|
|
@@ -357,7 +354,7 @@ async function cmdPair (args = []) {
|
|
|
357
354
|
console.log('\nEsperando a que el dispositivo se conecte… (Ctrl+C para salir)')
|
|
358
355
|
for (let i = 0; i < 1500; i++) { // ~2.5 min
|
|
359
356
|
await sleep(100)
|
|
360
|
-
const pe =
|
|
357
|
+
const pe = ipcRead(pendingFile, null)
|
|
361
358
|
if (pe?.deviceId) { showChallenge(pe); return }
|
|
362
359
|
}
|
|
363
360
|
console.log('\nNingún dispositivo se conectó aún. Cuando lo haga: dotrino-vault pending')
|
|
@@ -365,7 +362,7 @@ async function cmdPair (args = []) {
|
|
|
365
362
|
|
|
366
363
|
function cmdPending () {
|
|
367
364
|
requireDaemon()
|
|
368
|
-
const pe =
|
|
365
|
+
const pe = ipcRead(pendingFile, null)
|
|
369
366
|
if (!pe?.deviceId) { console.log('No hay ningún dispositivo pendiente de aprobar.'); return }
|
|
370
367
|
showChallenge(pe)
|
|
371
368
|
}
|
|
@@ -441,7 +438,7 @@ async function cmdJoin (rest) {
|
|
|
441
438
|
let visto = null
|
|
442
439
|
for (let i = 0; i < 900; i++) { // hasta 3 min: hay un humano tipeando al otro lado
|
|
443
440
|
await sleep(200)
|
|
444
|
-
const d =
|
|
441
|
+
const d = ipcRead(res, null)
|
|
445
442
|
if (!d) continue
|
|
446
443
|
if (d.code && d.code !== visto) {
|
|
447
444
|
visto = d.code
|
|
@@ -485,7 +482,7 @@ async function cmdMe () {
|
|
|
485
482
|
writeReq('me-request.json', {})
|
|
486
483
|
sendSignal(s.pid, 'SIGUSR2')
|
|
487
484
|
let dump = null
|
|
488
|
-
for (let n = 0; n < 50; n++) { await sleep(100); const d =
|
|
485
|
+
for (let n = 0; n < 50; n++) { await sleep(100); const d = ipcRead(meFile, null); if (d?.at) { dump = d; break } }
|
|
489
486
|
// El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
|
|
490
487
|
try { fs.rmSync(meFile, { force: true }) } catch (_) {}
|
|
491
488
|
if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
|
|
@@ -535,7 +532,7 @@ async function cmdMembers () {
|
|
|
535
532
|
writeReq('dump-request.json', {})
|
|
536
533
|
sendSignal(s.pid, 'SIGUSR2')
|
|
537
534
|
let record = null
|
|
538
|
-
for (let i = 0; i < 50; i++) { await sleep(100); const a =
|
|
535
|
+
for (let i = 0; i < 50; i++) { await sleep(100); const a = ipcRead(recordFile, null); if (a?.at) { record = a; break } }
|
|
539
536
|
if (!record) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
540
537
|
assertOpen(record)
|
|
541
538
|
if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
@@ -615,7 +612,7 @@ async function findMember (id) {
|
|
|
615
612
|
writeReq('dump-request.json', {})
|
|
616
613
|
sendSignal(s.pid, 'SIGUSR2')
|
|
617
614
|
let record = null
|
|
618
|
-
for (let i = 0; i < 50; i++) { await sleep(100); const a =
|
|
615
|
+
for (let i = 0; i < 50; i++) { await sleep(100); const a = ipcRead(recordFile, null); if (a?.at) { record = a; break } }
|
|
619
616
|
assertOpen(record)
|
|
620
617
|
const m = record?.members?.find((x) => x.id === String(id).toUpperCase())
|
|
621
618
|
if (!m) { console.error('No hay ningún dispositivo con ese identificador. Míralos con: dotrino-vault members'); process.exit(1) }
|
|
@@ -695,7 +692,7 @@ async function cmdDevices () {
|
|
|
695
692
|
writeReq('dump-request.json', {}) // de qué perfil queremos los dispositivos
|
|
696
693
|
sendSignal(s.pid, 'SIGUSR2')
|
|
697
694
|
let snap = null
|
|
698
|
-
for (let i = 0; i < 50; i++) { await sleep(100); const d =
|
|
695
|
+
for (let i = 0; i < 50; i++) { await sleep(100); const d = ipcRead(devFile, null); if (d?.at) { snap = d; break } }
|
|
699
696
|
if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
700
697
|
assertOpen(snap)
|
|
701
698
|
const revoked = snap.revoked || []
|
|
@@ -775,6 +772,29 @@ function profileDir () {
|
|
|
775
772
|
|
|
776
773
|
// DE DÓNDE SALE la clave que cifra el disco (`lib/src/kek.js`). Por defecto se deriva de
|
|
777
774
|
// esta máquina; se puede pasar a un KMS, y entonces una copia del disco deja de servir.
|
|
775
|
+
/**
|
|
776
|
+
* Archivos de un directorio que NO van cifrados y no tienen por qué estar ahí.
|
|
777
|
+
*
|
|
778
|
+
* La lista de excepciones es la misma que justifica el smoke `reposo.mjs` de
|
|
779
|
+
* `dotrino-test`: el salt y la huella son material del propio cifrado, el candado se lee
|
|
780
|
+
* antes de tener clave, `key.json` es una pública que dice de quién es la carpeta, y las
|
|
781
|
+
* preferencias no son de nadie. Todo lo demás en claro es un hallazgo.
|
|
782
|
+
*/
|
|
783
|
+
function enClaroEn (d) {
|
|
784
|
+
const OK = new Set(['atrest.salt', 'atrest.machine', 'atrest.kek', 'atrest.json', 'vault.lock', 'prefs.json', 'key.json'])
|
|
785
|
+
let names = []
|
|
786
|
+
try { names = fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch (_) { return [] }
|
|
787
|
+
return names.filter((n) => {
|
|
788
|
+
if (OK.has(n) || n.endsWith('.tmp')) return false
|
|
789
|
+
try {
|
|
790
|
+
const texto = fs.readFileSync(path.join(d, n), 'utf8')
|
|
791
|
+
if (!texto.trim()) return false
|
|
792
|
+
// La bitácora va cifrada línea a línea: basta con mirar la primera.
|
|
793
|
+
return !(n === 'activity.log' ? (texto.split('\n')[0] || '') : texto).startsWith('DOTRINO-ATREST-v1.')
|
|
794
|
+
} catch (_) { return false }
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
|
|
778
798
|
function cmdAtrest (rest) {
|
|
779
799
|
// OJO: la clave es POR PERFIL. `dir` aquí dentro es el del perfil al que apunta el
|
|
780
800
|
// comando (--profile, o el activo); la raíz de la bóveda es otra cosa y tiene la suya.
|
|
@@ -795,6 +815,20 @@ function cmdAtrest (rest) {
|
|
|
795
815
|
}
|
|
796
816
|
console.log('Archivos cifrados: ' + (archivos.length ? archivos.join(', ') : 'ninguno todavía'))
|
|
797
817
|
|
|
818
|
+
// Y LO QUE NO LO ESTÁ, que es la mitad que faltaba: listar lo cifrado no dice nada de
|
|
819
|
+
// lo que quedó fuera. Así apareció `profiles.json.pre041.bak` —un respaldo que una
|
|
820
|
+
// versión vieja dejó en claro, con el verificador de la contraseña dentro— meses
|
|
821
|
+
// después de que su migración terminara. Se mira el perfil Y la raíz, porque el canal
|
|
822
|
+
// local con la CLI vive arriba.
|
|
823
|
+
for (const [donde, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
|
|
824
|
+
const sueltos = enClaroEn(d)
|
|
825
|
+
if (sueltos.length) {
|
|
826
|
+
console.log('')
|
|
827
|
+
console.log(`${R}En claro en ${donde}:${Z} ` + sueltos.join(', '))
|
|
828
|
+
console.log(' Si es un respaldo de una migración terminada, bórralo: no lo cifra nadie.')
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
798
832
|
// ES POR PERFIL, y eso hay que verlo: en la misma bóveda un perfil puede ir con KMS
|
|
799
833
|
// y el de al lado con la clave de la máquina. Sin esta lista, «lo puse en el KMS» es
|
|
800
834
|
// una creencia y no un hecho comprobable.
|
|
@@ -898,6 +932,10 @@ function cmdActivity (n = 30) {
|
|
|
898
932
|
try { lines = fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) } catch {
|
|
899
933
|
console.log('Sin actividad registrada todavía (o el servicio es anterior a 0.1.10).'); return
|
|
900
934
|
}
|
|
935
|
+
// Cada entrada va cifrada por separado (ver `audit` en `vault.js`). `decrypt` deja pasar
|
|
936
|
+
// el texto en claro, así que una bitácora anterior a 0.89 se sigue leyendo entera.
|
|
937
|
+
const atRest = atRestFor(profileDir())
|
|
938
|
+
lines = lines.map((l) => { try { return atRest.decrypt(l) } catch { return l } })
|
|
901
939
|
const ICON = { sign: '🖊 firma', renew: '♻ renovación', enroll: '➕ enrolado', revoke: '⛔ revocado', rejected: '🚫 RECHAZADO', secrets: '🔑 secretos leídos', 'secret.set': '🔑 secreto guardado', 'secret.rm': '🔑 secreto borrado' }
|
|
902
940
|
for (const line of lines.slice(-n)) {
|
|
903
941
|
try {
|
|
@@ -1011,7 +1049,7 @@ async function cmdSecret (rest) {
|
|
|
1011
1049
|
sendSignal(s.pid, 'SIGUSR2')
|
|
1012
1050
|
for (let i = 0; i < 50; i++) {
|
|
1013
1051
|
await sleep(100)
|
|
1014
|
-
const d =
|
|
1052
|
+
const d = ipcRead(secretsListFile, null)
|
|
1015
1053
|
// El volcado lleva el valor de las públicas: se borra en cuanto se tiene, para que no
|
|
1016
1054
|
// se quede esperando en el disco a que copien la carpeta (ver `daemon.js`).
|
|
1017
1055
|
if (d?.at) { try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {} ; return assertOpen(d) }
|
|
@@ -1056,8 +1094,8 @@ async function cmdSecret (rest) {
|
|
|
1056
1094
|
'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
|
|
1057
1095
|
'cambio de configuración y el servicio se reinicia a media carga.',
|
|
1058
1096
|
'',
|
|
1059
|
-
'Pública o privada dice
|
|
1060
|
-
'
|
|
1097
|
+
'Pública o privada dice DOS cosas: si al entregarla se te pide APROBACIÓN, y si quien',
|
|
1098
|
+
'administra puede ver su valor. Las dos se guardan cifradas igual. Se nace privada.'
|
|
1061
1099
|
].join('\n')
|
|
1062
1100
|
|
|
1063
1101
|
/**
|
|
@@ -1112,12 +1150,12 @@ async function cmdSecret (rest) {
|
|
|
1112
1150
|
/**
|
|
1113
1151
|
* Una variable en la lista: su nombre, y que TIENE valor. Ninguna lo enseña.
|
|
1114
1152
|
*
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
* una pública era peor que no enseñar nada: parecía que se había perdido.
|
|
1153
|
+
* Las dos van selladas igual desde 2026-09-02. Lo que las separa es que a quien administra
|
|
1154
|
+
* se le hace la envoltura de las PÚBLICAS y la bóveda le manda su sobre, así que puede
|
|
1155
|
+
* abrirlas; de una privada de un cajón con dueño no tiene envoltura ni recibe sobre.
|
|
1119
1156
|
*/
|
|
1120
|
-
const printVar = (k) => console.log(' · %s
|
|
1157
|
+
const printVar = (k) => console.log(' · %s %s', k.key,
|
|
1158
|
+
k.public ? `${k.value ?? '••••••'} (pública)` : '••••••')
|
|
1121
1159
|
/**
|
|
1122
1160
|
* Por qué no se aplicó. El daemon deja el motivo en el volcado siguiente; sin él lo
|
|
1123
1161
|
* único que se podía decir era «revisa los logs», que no ayuda a quien acaba de
|
|
@@ -1187,7 +1225,7 @@ async function cmdSecret (rest) {
|
|
|
1187
1225
|
// QUÉ SIGNIFICA HOY (dueño, 2026-09-02): todas se guardan selladas igual, y `pública`
|
|
1188
1226
|
// solo dice a quién se le entrega SIN APROBACIÓN. Decir «su valor se puede ver» era
|
|
1189
1227
|
// verdad cuando se guardaba en claro; dejarlo ahí sería prometer algo que ya no pasa.
|
|
1190
|
-
console.log('\n(pública) = se entrega sin pedirte aprobación. Todas
|
|
1228
|
+
console.log('\n(pública) = se entrega sin pedirte aprobación y quien administra puede leerla. Todas van cifradas.\n')
|
|
1191
1229
|
printPending(d)
|
|
1192
1230
|
await printNoPassword()
|
|
1193
1231
|
return
|
|
@@ -1335,7 +1373,7 @@ async function profileRequest (op, extra = {}) {
|
|
|
1335
1373
|
sendSignal(s.pid, 'SIGUSR2')
|
|
1336
1374
|
for (let i = 0; i < 100; i++) {
|
|
1337
1375
|
await sleep(100)
|
|
1338
|
-
const d =
|
|
1376
|
+
const d = ipcRead(profilesFile, null)
|
|
1339
1377
|
if (d?.at) return d
|
|
1340
1378
|
}
|
|
1341
1379
|
console.error('El daemon no respondió.'); process.exit(1)
|
package/src/daemon.js
CHANGED
|
@@ -18,12 +18,14 @@ import fs from 'node:fs'
|
|
|
18
18
|
import path from 'node:path'
|
|
19
19
|
import { takeLock } from '../lib/src/lock.js'
|
|
20
20
|
import { startVaultManager } from './manager.js'
|
|
21
|
-
import { dataDir
|
|
21
|
+
import { dataDir } from './paths.js'
|
|
22
|
+
// EL CANAL LOCAL VA CIFRADO: por aquí pasan la contraseña, los valores y las invitaciones.
|
|
23
|
+
import { ipcRead, ipcWrite } from './ipc.js'
|
|
22
24
|
import { parseInvite } from '../lib/src/invite.js'
|
|
23
25
|
import { watchBinary } from './selfupdate.js'
|
|
24
26
|
import { VERSION } from './version.js'
|
|
25
27
|
|
|
26
|
-
const readJsonSafe = (f) =>
|
|
28
|
+
const readJsonSafe = (f) => ipcRead(f, null)
|
|
27
29
|
const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -73,7 +75,7 @@ export async function runDaemon () {
|
|
|
73
75
|
// Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
|
|
74
76
|
// quiere entrar) para que el dueño lo compare con el del dispositivo y apruebe.
|
|
75
77
|
const onEnrollChallenge = ({ deviceId, scope, profile, profileName }) => {
|
|
76
|
-
|
|
78
|
+
ipcWrite(pendingEnrollFile, { v: 2, at: Date.now(), deviceId, scope, profile, profileName })
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
|
|
@@ -86,7 +88,7 @@ export async function runDaemon () {
|
|
|
86
88
|
// en `profiles`.
|
|
87
89
|
const writeState = () => {
|
|
88
90
|
const cur = mgr.summary().find((p) => p.current) || {}
|
|
89
|
-
|
|
91
|
+
ipcWrite(stateFile, {
|
|
90
92
|
v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
|
|
91
93
|
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
|
|
92
94
|
// Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
|
|
@@ -103,7 +105,7 @@ export async function runDaemon () {
|
|
|
103
105
|
// contesta a lo mío». Sin esa marca, un repaso que caía en medio pasaba por respuesta:
|
|
104
106
|
// el `unlock` se daba por hecho con la foto de cuando aún estaba cerrada, y un
|
|
105
107
|
// «contraseña incorrecta» se perdía por el camino sin que nadie lo llegara a ver.
|
|
106
|
-
const dumpProfiles = (extra = {}) => { writeState();
|
|
108
|
+
const dumpProfiles = (extra = {}) => { writeState(); ipcWrite(profilesFile, { v: 1, at: Date.now(), req: null, current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
|
|
107
109
|
|
|
108
110
|
console.log(`dotrino-vault · datos en ${dir} · proxy ${proxyUrl}`)
|
|
109
111
|
for (const p of mgr.summary()) {
|
|
@@ -174,7 +176,7 @@ export async function runDaemon () {
|
|
|
174
176
|
if (locked?.locked) {
|
|
175
177
|
// Se responde por el MISMO archivo que espera quien pidió el QR: si no, se queda
|
|
176
178
|
// mirando una pantalla vacía hasta que se agote el tiempo, sin saber por qué.
|
|
177
|
-
|
|
179
|
+
ipcWrite(pairFile, { v: 2, at: Date.now(), profile: locked.id, locked: true })
|
|
178
180
|
return console.error('[vault] profile %s is locked: pairing refused', locked.id)
|
|
179
181
|
}
|
|
180
182
|
const vault = targetOf(pairReq)
|
|
@@ -189,7 +191,7 @@ export async function runDaemon () {
|
|
|
189
191
|
const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
|
|
190
192
|
const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
|
|
191
193
|
if (asked && asked.some((x) => !ALLOWED(x))) {
|
|
192
|
-
|
|
194
|
+
ipcWrite(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
|
|
193
195
|
return console.error('[vault] pairing refused: scope not allowed (%s)', asked.join(','))
|
|
194
196
|
}
|
|
195
197
|
const scope = asked?.length
|
|
@@ -213,7 +215,7 @@ export async function runDaemon () {
|
|
|
213
215
|
// crea uno vacío), o no habría dónde meterla.
|
|
214
216
|
const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
|
|
215
217
|
const { qr, expiresInMs } = await vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
|
|
216
|
-
|
|
218
|
+
ipcWrite(pairFile, { v: 2, at: Date.now(), qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
|
|
217
219
|
// El token es un secreto efímero: no debe quedar en disco más allá de su
|
|
218
220
|
// vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
|
|
219
221
|
const tok = qr.token
|
|
@@ -394,7 +396,7 @@ export async function runDaemon () {
|
|
|
394
396
|
// esta consola: quien aprobaba desde la TUI leía «Dispositivo aprobado», el
|
|
395
397
|
// pendiente desaparecía de la pantalla y el aparato seguía esperando al otro lado
|
|
396
398
|
// sin que nadie pudiera reintentar.
|
|
397
|
-
const answer = (extra) =>
|
|
399
|
+
const answer = (extra) => ipcWrite(approveFile, { v: 1, at: Date.now(), req: appr.id || null, ...extra })
|
|
398
400
|
try {
|
|
399
401
|
const vault = targetOf(appr)
|
|
400
402
|
if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
|
|
@@ -482,7 +484,7 @@ export async function runDaemon () {
|
|
|
482
484
|
const off = id.onVault?.((e) => {
|
|
483
485
|
if (e?.phase === 'challenge' && e.code) {
|
|
484
486
|
console.log('[vault] type this code in the other vault: %s', e.code)
|
|
485
|
-
|
|
487
|
+
ipcWrite(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
|
|
486
488
|
}
|
|
487
489
|
})
|
|
488
490
|
// `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
|
|
@@ -498,13 +500,13 @@ export async function runDaemon () {
|
|
|
498
500
|
// vacío no es más que un accidente del primer arranque.
|
|
499
501
|
try { mgr.profiles.setCurrent(p.id) } catch (_) {}
|
|
500
502
|
console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r?.acta?.seq ?? '?', p.id)
|
|
501
|
-
|
|
503
|
+
ipcWrite(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
|
|
502
504
|
} catch (e) {
|
|
503
505
|
console.error('[vault] could not join:', e.message)
|
|
504
506
|
// El perfil nació para esto y está vacío: si el intento no llegó a término se va
|
|
505
507
|
// con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
|
|
506
508
|
if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
|
|
507
|
-
|
|
509
|
+
ipcWrite(joinResFile, { at: Date.now(), state: 'error', error: e.message })
|
|
508
510
|
}
|
|
509
511
|
}
|
|
510
512
|
// Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
|
|
@@ -641,12 +643,12 @@ export async function runDaemon () {
|
|
|
641
643
|
// nombres de variables, ni acta. Antes el candado no tapaba ninguna de las tres.
|
|
642
644
|
const closed = { v: 1, at: Date.now(), req: reqId, profile: t.id, locked: true }
|
|
643
645
|
if (dumpReq) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
646
|
+
ipcWrite(secretsListFile, { ...closed, ns: {}, dev: [] })
|
|
647
|
+
ipcWrite(devFile, { ...closed, issued: [], revoked: [] })
|
|
648
|
+
ipcWrite(path.join(dir, 'acta.json'), { ...closed, members: [] })
|
|
647
649
|
}
|
|
648
650
|
rm(meReqFile)
|
|
649
|
-
if (meReq)
|
|
651
|
+
if (meReq) ipcWrite(meFile, { ...closed, req: meReq.id || null, me: null })
|
|
650
652
|
return
|
|
651
653
|
}
|
|
652
654
|
if (dumpReq) {
|
|
@@ -658,7 +660,7 @@ export async function runDaemon () {
|
|
|
658
660
|
// en cuanto lo tiene). Es material que su dueño marcó como mostrable, y aquí ya
|
|
659
661
|
// viaja a un navegador; lo que NO puede pasar es que se quede en el disco esperando
|
|
660
662
|
// a que alguien copie la carpeta, porque eso sí burlaría el cifrado en reposo.
|
|
661
|
-
|
|
663
|
+
ipcWrite(secretsListFile, {
|
|
662
664
|
v: 2, at: Date.now(), req: reqId, profile: t.id,
|
|
663
665
|
ns: t.vault.listSecrets(),
|
|
664
666
|
dev: await t.vault.listDeviceSecrets(),
|
|
@@ -676,9 +678,9 @@ export async function runDaemon () {
|
|
|
676
678
|
lastSecretError = null
|
|
677
679
|
lastSecretValue = null
|
|
678
680
|
lastSecretHistory = null
|
|
679
|
-
|
|
681
|
+
ipcWrite(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
|
|
680
682
|
// Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
|
|
681
|
-
try {
|
|
683
|
+
try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
|
|
682
684
|
}
|
|
683
685
|
|
|
684
686
|
// PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
|
|
@@ -692,7 +694,7 @@ export async function runDaemon () {
|
|
|
692
694
|
const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
|
|
693
695
|
const { me } = tm.vault.threads.methods.profileGet()
|
|
694
696
|
const { avatar, ...rest } = me || {}
|
|
695
|
-
|
|
697
|
+
ipcWrite(meFile, { v: 1, at: Date.now(), req: meReq.id || null, profile: tm.id, me: me ? { ...rest, avatar: avatarInfo(avatar) } : null })
|
|
696
698
|
} catch (e) { console.error('[vault] could not dump the profile:', e.message) }
|
|
697
699
|
}
|
|
698
700
|
} catch (e) {
|
|
@@ -806,8 +808,8 @@ export async function runDaemon () {
|
|
|
806
808
|
const hecho = readJsonSafe(marca)
|
|
807
809
|
if (hecho?.sn === qr.sn) return // ya se usó: un reinicio no vuelve a pedir entrar
|
|
808
810
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
+
ipcWrite(marca, { at: Date.now(), sn: qr.sn, ...(qr.iss ? { iss: qr.iss } : {}) })
|
|
812
|
+
ipcWrite(path.join(dir, 'join-request.json'), {
|
|
811
813
|
qr,
|
|
812
814
|
label: 'bóveda',
|
|
813
815
|
...(process.env.DOTRINO_JOIN_NAME ? { name: process.env.DOTRINO_JOIN_NAME } : {})
|
package/src/ipc.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc.js — el CANAL LOCAL entre el daemon y la CLI, **cifrado en reposo**.
|
|
3
|
+
*
|
|
4
|
+
* El daemon y el `dotrino-vault` de tu shell no hablan por un socket: se dejan archivos
|
|
5
|
+
* JSON en el directorio de datos (`state.json`, `acta.json`, `secret-request.json`…, ver
|
|
6
|
+
* la cabecera de `daemon.js`). Ese contrato es cómodo y no escucha en ningún puerto, pero
|
|
7
|
+
* durante meses **fue el único sitio del vault que escribía en claro**, y por ahí pasa de
|
|
8
|
+
* todo:
|
|
9
|
+
*
|
|
10
|
+
* · `secret-request.json` la CONTRASEÑA del perfil y el VALOR de lo que se guarda
|
|
11
|
+
* · `secrets-list.json` el valor recién destapado, esperando a que la CLI lo recoja
|
|
12
|
+
* · `pair.json` la invitación de emparejamiento, que es un secreto
|
|
13
|
+
* · `acta.json`/`devices.json` quién es miembro, con qué permisos y qué certificados
|
|
14
|
+
* · `me.json`, `profiles-list.json` el perfil y las públicas maestras de cada uno
|
|
15
|
+
*
|
|
16
|
+
* Que los archivos fueran efímeros no salvaba nada: se escriben en el disco igual, y un
|
|
17
|
+
* `rm` no borra lo que ya se copió. Ahora van por el MISMO cifrado en reposo que el resto
|
|
18
|
+
* (`atrest.js`), que es lo correcto aquí y no un apaño: los dos extremos corren en esta
|
|
19
|
+
* máquina y con este usuario, así que comparten la clave sin negociar nada.
|
|
20
|
+
*
|
|
21
|
+
* **Por qué no un sobre.** Un sobre se cierra contra la pública de quien va a abrirlo, y
|
|
22
|
+
* el que abre esto es tu CLI, que no tiene llave propia — inventarle una la dejaría en el
|
|
23
|
+
* mismo disco y al lado, que es exactamente lo que no protege de nada. El sobre es para lo
|
|
24
|
+
* que VIAJA a otro; para lo que se queda en la máquina, el reposo.
|
|
25
|
+
*
|
|
26
|
+
* Ojo con el dir: todos estos archivos cuelgan del directorio raíz, así que la clave sale
|
|
27
|
+
* de ahí (`path.dirname`). Un archivo de perfil tiene otro salt y NO se lee con esta.
|
|
28
|
+
*/
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { atRestFor } from './atrest.js'
|
|
31
|
+
import { readJson, writeJson } from './paths.js'
|
|
32
|
+
|
|
33
|
+
const codecOf = (file) => atRestFor(path.dirname(file))
|
|
34
|
+
|
|
35
|
+
/** Lee un archivo del canal. `fallback` si no está o no se puede abrir. */
|
|
36
|
+
export const ipcRead = (file, fallback = null) => readJson(file, fallback, codecOf(file))
|
|
37
|
+
|
|
38
|
+
/** Escribe un archivo del canal, cifrado y 0600 (atómico: tmp + rename). */
|
|
39
|
+
export const ipcWrite = (file, obj) => writeJson(file, obj, codecOf(file))
|
|
40
|
+
|
|
41
|
+
export default { ipcRead, ipcWrite }
|
package/src/node-globals.js
CHANGED
|
@@ -8,14 +8,25 @@
|
|
|
8
8
|
import fs from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import WebSocket from 'ws'
|
|
11
|
+
import { atRestFor } from './atrest.js'
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* localStorage síncrono respaldado por un archivo JSON (solo lo que usan los paquetes)
|
|
15
|
+
* **y cifrado en reposo**.
|
|
16
|
+
*
|
|
17
|
+
* Lo de cifrarlo no es celo: aquí `@dotrino/proxy-client` guarda su par de transporte, y
|
|
18
|
+
* ese par lleva su PRIVADA. Era el único archivo del vault con una llave privada en claro
|
|
19
|
+
* en el disco. No firma nada que el acta reconozca —quien habla por la bóveda es la llave
|
|
20
|
+
* de comunicación (`commKey.js`)—, pero sí es la identidad con la que esta bóveda se
|
|
21
|
+
* sienta en el proxio: con ella se ocupa su sitio y su cola de mensajes.
|
|
22
|
+
*/
|
|
13
23
|
export function fileLocalStorage (filePath) {
|
|
24
|
+
const atRest = atRestFor(path.dirname(filePath))
|
|
14
25
|
let data = {}
|
|
15
|
-
try { if (fs.existsSync(filePath)) data = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {} } catch (_) {}
|
|
26
|
+
try { if (fs.existsSync(filePath)) data = JSON.parse(atRest.decrypt(fs.readFileSync(filePath, 'utf8'))) || {} } catch (_) {}
|
|
16
27
|
const flush = () => {
|
|
17
28
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
18
|
-
fs.writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 })
|
|
29
|
+
fs.writeFileSync(filePath, atRest.encrypt(JSON.stringify(data)), { mode: 0o600 })
|
|
19
30
|
}
|
|
20
31
|
return {
|
|
21
32
|
getItem: (k) => (k in data ? data[k] : null),
|
package/src/secretsStore.js
CHANGED
|
@@ -618,6 +618,24 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
618
618
|
return { gen }
|
|
619
619
|
},
|
|
620
620
|
|
|
621
|
+
/**
|
|
622
|
+
* LA ENTRADA TAL CUAL (sobre, generación, visibilidad) — sin abrir nada.
|
|
623
|
+
*
|
|
624
|
+
* Es lo que hace falta para MANDARLE a alguien una variable que sí puede leer: el sobre
|
|
625
|
+
* viaja cerrado y lo abre en su casa, con su envoltura. Aquí no se descifra.
|
|
626
|
+
*/
|
|
627
|
+
entryOf (owner, key) {
|
|
628
|
+
const [kind, k] = splitOwner(owner)
|
|
629
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
630
|
+
return varsOf(bag, k)[key] || null
|
|
631
|
+
},
|
|
632
|
+
/** La envoltura de ESA generación para ESE miembro, o `null` si no la tiene. */
|
|
633
|
+
wrapOf (owner, gen, memberPub) {
|
|
634
|
+
const [kind, k] = splitOwner(owner)
|
|
635
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
636
|
+
return (bag[k]?.keyring || []).find((g) => g.gen === gen)?.wraps?.[memberPub] || null
|
|
637
|
+
},
|
|
638
|
+
|
|
621
639
|
/**
|
|
622
640
|
* ¿EXISTE YA ESTA VARIABLE? Sin abrirla ni decir qué vale.
|
|
623
641
|
*
|
package/src/vault.js
CHANGED
|
@@ -321,15 +321,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
321
321
|
// AUDITORÍA: bitácora de actividad de seguridad (activity.log, JSONL) — qué
|
|
322
322
|
// dispositivo firmó/renovó/enroló y qué se rechazó. `dotrino-vault activity`
|
|
323
323
|
// la muestra. Sin contenido de payloads (privacidad): solo op, dispositivo, hora.
|
|
324
|
+
//
|
|
325
|
+
// VA CIFRADA, y una línea a la vez. Antes era el único archivo excluido del reposo, con
|
|
326
|
+
// el argumento de que «no guarda payloads»: no guarda valores, pero sí el mapa entero de
|
|
327
|
+
// la cuenta —qué aparatos hay, con qué permisos, contra qué cajones y a qué hora—, que
|
|
328
|
+
// es justo lo que quiere quien está eligiendo por dónde entrar. Y encima se creaba 0664,
|
|
329
|
+
// legible por cualquier usuario de la máquina.
|
|
330
|
+
//
|
|
331
|
+
// Cifrar por línea (y no el archivo entero) es lo que deja seguir AÑADIENDO: cada
|
|
332
|
+
// entrada se cierra sola, así que un `appendFileSync` sigue costando lo mismo y la
|
|
333
|
+
// rotación no necesita abrir nada.
|
|
324
334
|
const activityFile = path.join(dir, 'activity.log')
|
|
335
|
+
const activityAtRest = atRestFor(dir)
|
|
325
336
|
const audit = (op, info = {}) => {
|
|
326
337
|
try {
|
|
327
|
-
|
|
338
|
+
const line = activityAtRest.encrypt(JSON.stringify({ ts: Date.now(), op, ...info }))
|
|
339
|
+
fs.appendFileSync(activityFile, line + '\n', { mode: 0o600 })
|
|
340
|
+
// El `mode` de `appendFileSync` solo aplica al CREAR: las bitácoras de antes de
|
|
341
|
+
// 0.89 ya existen con 0664 y hay que estrecharlas a mano.
|
|
342
|
+
try { if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600) } catch (_) {}
|
|
328
343
|
// rotación simple: si pasa de ~1 MB, conservar la última mitad
|
|
329
344
|
const st = fs.statSync(activityFile)
|
|
330
345
|
if (st.size > 1024 * 1024) {
|
|
331
346
|
const lines = fs.readFileSync(activityFile, 'utf8').split('\n')
|
|
332
|
-
fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'))
|
|
347
|
+
fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'), { mode: 0o600 })
|
|
333
348
|
}
|
|
334
349
|
} catch (_) {}
|
|
335
350
|
}
|
|
@@ -1358,14 +1373,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1358
1373
|
}
|
|
1359
1374
|
|
|
1360
1375
|
const varsDesk = {
|
|
1361
|
-
async list () {
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1376
|
+
async list ({ caller = null } = {}) {
|
|
1377
|
+
// EL VAULT DECIDE QUÉ ENVÍA PARA LEER (dueño, 2026-09-02: «la consola debe tener todos
|
|
1378
|
+
// los sobres; la diferencia es si el vault se los envía para la lectura o no»).
|
|
1379
|
+
//
|
|
1380
|
+
// Tener la envoltura y recibir el sobre son dos cosas: quien administra tiene la
|
|
1381
|
+
// envoltura de cada PÚBLICA, y por eso aquí se le manda el sobre de esas y solo de
|
|
1382
|
+
// esas. Las privadas de un cajón con dueño no se mandan — y aunque se mandaran no
|
|
1383
|
+
// podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
|
|
1384
|
+
// propósito: que uno falle no basta para leerlas.
|
|
1385
|
+
//
|
|
1364
1386
|
// Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
|
|
1365
1387
|
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
1366
1388
|
return {
|
|
1367
1389
|
enc: await identity.sealContent(JSON.stringify({
|
|
1368
|
-
ns: listSecrets(), dev: await listDeviceSecrets(),
|
|
1390
|
+
ns: conSobres(listSecrets(), 'ns', caller), dev: conSobres(await listDeviceSecrets(), 'dev', caller),
|
|
1369
1391
|
// Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
|
|
1370
1392
|
// enseñarlo donde se administra, no solo en el log del servicio.
|
|
1371
1393
|
incomplete: await incompleteMembers(),
|
|
@@ -1572,7 +1594,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1572
1594
|
*/
|
|
1573
1595
|
async function nsMembers (ns) {
|
|
1574
1596
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1575
|
-
|
|
1597
|
+
if (!record) return []
|
|
1598
|
+
// LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
|
|
1599
|
+
// lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
|
|
1600
|
+
//
|
|
1601
|
+
// Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
|
|
1602
|
+
// DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
|
|
1603
|
+
// así que un cajón llamado `vault` —nombre perfectamente válido— la contaba entre sus
|
|
1604
|
+
// dueños. No llegaba a recibir sobre (no tiene `encPub`, y `recipientsFor` los filtra),
|
|
1605
|
+
// pero la salvaguarda era un accidente: bastaba que alguien le apuntara una `encPub`
|
|
1606
|
+
// para envolverle la llave a la llave que firma con la bóveda CERRADA. Se comprueba el
|
|
1607
|
+
// permiso, que es lo que de verdad se quería preguntar.
|
|
1608
|
+
return (record.members || []).filter((m) => m.cn === ns && Acta.memberCanReadSecrets(record, m.pub, ns))
|
|
1576
1609
|
}
|
|
1577
1610
|
|
|
1578
1611
|
/**
|
|
@@ -2327,6 +2360,34 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2327
2360
|
* de esta máquina»: taparlo justo aquí —en la máquina donde vive, delante de su dueño—
|
|
2328
2361
|
* era lo único que la marca no significaba. La consola remota ya las enseña.
|
|
2329
2362
|
*/
|
|
2363
|
+
/**
|
|
2364
|
+
* LE PONE A CADA PÚBLICA SU SOBRE Y LA ENVOLTURA DE QUIEN PREGUNTA, para que pueda leerla.
|
|
2365
|
+
*
|
|
2366
|
+
* Solo a las públicas: es la política de despacho, y es lo único que cambia entre una y
|
|
2367
|
+
* otra. A una privada no se le manda nada — y aunque se le mandara, quien administra no
|
|
2368
|
+
* tiene su envoltura en un cajón con dueño, así que no la abriría igual.
|
|
2369
|
+
*
|
|
2370
|
+
* Si falta la envoltura de quien pregunta (todavía no se le ha hecho, o el cajón se
|
|
2371
|
+
* escribió antes de que entrara) se manda la variable SIN sobre: se ve que existe y que es
|
|
2372
|
+
* pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
|
|
2373
|
+
* un valor o esconder la fila sería peor.
|
|
2374
|
+
*/
|
|
2375
|
+
const conSobres = (lista, tipo, caller) => {
|
|
2376
|
+
if (!caller) return lista
|
|
2377
|
+
const dar = (owner, k) => {
|
|
2378
|
+
if (!k.public) return k
|
|
2379
|
+
const e = secrets.entryOf?.(owner, k.key)
|
|
2380
|
+
const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
|
|
2381
|
+
return e?.e && wrap ? { ...k, e: e.e, wrap } : k
|
|
2382
|
+
}
|
|
2383
|
+
if (tipo === 'ns') {
|
|
2384
|
+
const out = {}
|
|
2385
|
+
for (const [ns, keys] of Object.entries(lista)) out[ns] = keys.map((k) => dar(`ns:${ns}`, k))
|
|
2386
|
+
return out
|
|
2387
|
+
}
|
|
2388
|
+
return lista.map((row) => ({ ...row, keys: row.keys.map((k) => dar(`dev:${row.pub}`, k)) }))
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2330
2391
|
function listSecrets () {
|
|
2331
2392
|
// YA NO SE ENSEÑA EL VALOR DE UNA PÚBLICA (dueño, 2026-09-02). Desde que todas van en
|
|
2332
2393
|
// sobre, «pública» dejó de significar «en claro»: dice a quién se le despacha sin
|
package/src/vaultControl.js
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
import fs from 'node:fs'
|
|
20
20
|
import path from 'node:path'
|
|
21
21
|
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
22
|
-
import { dataDir
|
|
22
|
+
import { dataDir } from './paths.js'
|
|
23
|
+
// El canal local va cifrado en reposo (ver `ipc.js`).
|
|
24
|
+
import { ipcRead, ipcWrite } from './ipc.js'
|
|
23
25
|
import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
|
|
24
26
|
|
|
25
27
|
const dir = dataDir()
|
|
@@ -49,7 +51,7 @@ const F = {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
const p = (name) => path.join(dir, name)
|
|
52
|
-
const read = (name, fb = null) =>
|
|
54
|
+
const read = (name, fb = null) => ipcRead(p(name), fb)
|
|
53
55
|
const rm = (name) => { try { fs.rmSync(p(name), { force: true }) } catch (_) {} }
|
|
54
56
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
55
57
|
|
|
@@ -120,12 +122,10 @@ async function writeReq (name, obj, profile) {
|
|
|
120
122
|
// nadie, se perdía: quien la había pedido esperaba seis segundos y leía «el daemon no
|
|
121
123
|
// respondió», con el daemon sano. `rename` es atómico dentro del mismo sistema de
|
|
122
124
|
// archivos, así que el vigilante o no ve nada, o ve la petición ENTERA.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// existía con permisos más laxos (defensa en profundidad; el dir ya es 0700).
|
|
128
|
-
try { fs.chmodSync(p(name), 0o600) } catch (_) {}
|
|
125
|
+
// `ipcWrite` hace las tres cosas: cifra en reposo, escribe en tmp y renombra, y deja
|
|
126
|
+
// el archivo en 0600 (el `mode` de `writeFileSync` solo aplica al CREAR, así que
|
|
127
|
+
// re-chmodea por si ya existía con permisos más laxos).
|
|
128
|
+
ipcWrite(p(name), body)
|
|
129
129
|
return id
|
|
130
130
|
}
|
|
131
131
|
|