@dotrino/vaultd 0.88.0 → 0.90.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/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
- * Queda fuera a propósito `activity.log`: es la bitácora de auditoría y no guarda
18
- * payloads (solo op, dispositivo y hora), y se quiere legible para diagnosticar.
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.88.0",
3
+ "version": "0.90.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, readJson } from './paths.js'
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 = readJson(stateFile, null)
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 = readJson(pairFile, null)
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 = readJson(pendingFile, null)
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 = readJson(pendingFile, null)
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 = readJson(res, null)
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 = readJson(meFile, null); if (d?.at) { dump = d; break } }
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 = readJson(recordFile, null); if (a?.at) { record = a; break } }
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 = readJson(recordFile, null); if (a?.at) { record = a; break } }
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 = readJson(devFile, null); if (d?.at) { snap = d; break } }
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 = readJson(secretsListFile, null)
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 UNA cosa: si al entregarla se te pide APROBACIÓN. Las dos se',
1060
- 'guardan cifradas igual y ninguna enseña su valor en la consola. Se nace privada.'
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
  /**
@@ -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 = readJson(profilesFile, null)
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, writeJson, readJson } from './paths.js'
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) => readJson(f, null)
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
- writeJson(pendingEnrollFile, { v: 2, at: Date.now(), deviceId, scope, profile, profileName })
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
- writeJson(stateFile, {
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(); writeJson(profilesFile, { v: 1, at: Date.now(), req: null, current: mgr.currentId(), profiles: mgr.summary(), ...extra }) }
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
- writeJson(pairFile, { v: 2, at: Date.now(), profile: locked.id, locked: true })
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
- writeJson(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
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
- writeJson(pairFile, { v: 2, at: Date.now(), qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
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) => writeJson(approveFile, { v: 1, at: Date.now(), req: appr.id || null, ...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
- writeJson(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
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
- writeJson(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
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
- writeJson(joinResFile, { at: Date.now(), state: 'error', error: e.message })
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
- writeJson(secretsListFile, { ...closed, ns: {}, dev: [] })
645
- writeJson(devFile, { ...closed, issued: [], revoked: [] })
646
- writeJson(path.join(dir, 'acta.json'), { ...closed, members: [] })
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) writeJson(meFile, { ...closed, req: meReq.id || null, me: null })
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
- writeJson(secretsListFile, {
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
- writeJson(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
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 { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
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
- writeJson(meFile, { v: 1, at: Date.now(), req: meReq.id || null, profile: tm.id, me: me ? { ...rest, avatar: avatarInfo(avatar) } : null })
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
- writeJson(marca, { at: Date.now(), sn: qr.sn, ...(qr.iss ? { iss: qr.iss } : {}) })
810
- writeJson(path.join(dir, 'join-request.json'), {
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 }
@@ -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
- /** localStorage síncrono respaldado por un archivo JSON (solo lo que usan los paquetes). */
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/vault.js CHANGED
@@ -321,15 +321,47 @@ 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)
336
+ // MIGRACIÓN de las bitácoras escritas antes de 0.89, que están en claro. Se convierten
337
+ // de una vez al arrancar: es un archivo acotado (rota al MB) y así «nada en claro» es
338
+ // cierto también para lo que ya estaba escrito, no solo para lo que se escriba desde
339
+ // ahora. Se agota sola — cuando no queda ninguna línea en claro, no vuelve a tocar nada.
340
+ try {
341
+ const crudo = fs.readFileSync(activityFile, 'utf8')
342
+ const lineas = crudo.split('\n')
343
+ if (lineas.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
344
+ const convertidas = lineas.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
345
+ // Escribir aparte y renombrar: a medio convertir, la bitácora sería ilegible entera.
346
+ fs.writeFileSync(activityFile + '.tmp', convertidas.join('\n'), { mode: 0o600 })
347
+ fs.renameSync(activityFile + '.tmp', activityFile)
348
+ log('[vault] activity log: %d line(s) sealed at rest', convertidas.filter(Boolean).length)
349
+ }
350
+ // Y el modo, que hasta 0.89 se creaba 0664: legible por cualquier usuario.
351
+ if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600)
352
+ } catch (_) { /* no hay bitácora todavía */ }
325
353
  const audit = (op, info = {}) => {
326
354
  try {
327
- fs.appendFileSync(activityFile, JSON.stringify({ ts: Date.now(), op, ...info }) + '\n')
355
+ const line = activityAtRest.encrypt(JSON.stringify({ ts: Date.now(), op, ...info }))
356
+ fs.appendFileSync(activityFile, line + '\n', { mode: 0o600 })
357
+ // El `mode` de `appendFileSync` solo aplica al CREAR: las bitácoras de antes de
358
+ // 0.89 ya existen con 0664 y hay que estrecharlas a mano.
359
+ try { if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600) } catch (_) {}
328
360
  // rotación simple: si pasa de ~1 MB, conservar la última mitad
329
361
  const st = fs.statSync(activityFile)
330
362
  if (st.size > 1024 * 1024) {
331
363
  const lines = fs.readFileSync(activityFile, 'utf8').split('\n')
332
- fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'))
364
+ fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'), { mode: 0o600 })
333
365
  }
334
366
  } catch (_) {}
335
367
  }
@@ -1579,7 +1611,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1579
1611
  */
1580
1612
  async function nsMembers (ns) {
1581
1613
  const record = (await identity.profileActa?.().catch(() => null))?.acta
1582
- return (record?.members || []).filter((m) => m.cn === ns)
1614
+ if (!record) return []
1615
+ // LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
1616
+ // lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
1617
+ //
1618
+ // Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
1619
+ // DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
1620
+ // así que un cajón llamado `vault` —nombre perfectamente válido— la contaba entre sus
1621
+ // dueños. No llegaba a recibir sobre (no tiene `encPub`, y `recipientsFor` los filtra),
1622
+ // pero la salvaguarda era un accidente: bastaba que alguien le apuntara una `encPub`
1623
+ // para envolverle la llave a la llave que firma con la bóveda CERRADA. Se comprueba el
1624
+ // permiso, que es lo que de verdad se quería preguntar.
1625
+ return (record.members || []).filter((m) => m.cn === ns && Acta.memberCanReadSecrets(record, m.pub, ns))
1583
1626
  }
1584
1627
 
1585
1628
  /**
@@ -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, readJson } from './paths.js'
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) => readJson(p(name), fb)
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
- const tmp = p(name) + '.tmp'
124
- fs.writeFileSync(tmp, JSON.stringify(body), { mode: 0o600 })
125
- fs.renameSync(tmp, p(name))
126
- // `mode` de writeFileSync solo aplica al CREAR: re-chmod por si el archivo ya
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