@dotrino/vaultd 0.93.1 → 0.94.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/ctl.js +64 -9
  3. package/src/vault.js +63 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.93.1",
3
+ "version": "0.94.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
@@ -24,6 +24,7 @@ import fs from 'node:fs'
24
24
  import path from 'node:path'
25
25
  import { execFileSync } from 'node:child_process'
26
26
  import tty from 'node:tty'
27
+ import crypto from 'node:crypto'
27
28
  import { pubkeyId } from '@dotrino/identity/capabilities'
28
29
  import { dataDir } from './paths.js'
29
30
  // EL CANAL LOCAL VA CIFRADO (ver `ipc.js`): por aquí salen la contraseña y los valores.
@@ -926,16 +927,66 @@ function cmdAtrest (rest) {
926
927
  }
927
928
 
928
929
  // Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
929
- function cmdActivity (n = 30) {
930
- const f = path.join(profileDir(), 'activity.log')
931
- let lines = []
932
- try { lines = fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) } catch {
930
+ /**
931
+ * Toda la bitácora EN ORDEN, archivos rotados incluidos. Se leen del más viejo al más
932
+ * nuevo (`.5` … `.1`, y al final el vivo) porque la cadena los cruza: verificarla mirando
933
+ * solo el archivo actual diría «rota» en su primera línea, que es exactamente lo contrario
934
+ * de lo que pasa.
935
+ */
936
+ function readActivity (dir) {
937
+ const atRest = atRestFor(dir)
938
+ const out = []
939
+ const files = []
940
+ for (let i = 9; i >= 1; i--) { const f = path.join(dir, `activity.log.${i}`); if (fs.existsSync(f)) files.push(f) }
941
+ files.push(path.join(dir, 'activity.log'))
942
+ for (const f of files) {
943
+ let raw = ''
944
+ try { raw = fs.readFileSync(f, 'utf8') } catch { continue }
945
+ for (const l of raw.trim().split('\n').filter(Boolean)) {
946
+ // `decrypt` deja pasar el texto en claro, así que una bitácora anterior a 0.89 se
947
+ // sigue leyendo entera.
948
+ try { out.push(atRest.decrypt(l)) } catch { out.push(l) }
949
+ }
950
+ }
951
+ return out
952
+ }
953
+
954
+ /**
955
+ * ¿ESTÁ LA CADENA ENTERA? Cada entrada lleva el hash de la anterior, así que tocar o quitar
956
+ * una rompe todo lo que va detrás. Esto lo recorre y dice dónde se rompió.
957
+ *
958
+ * Lo que NO comprueba, y se dice: que no le hayan cortado el FINAL. Un prefijo válido sigue
959
+ * siendo válido; cerrarlo pide anclar el último hash fuera de esta máquina.
960
+ */
961
+ function verifyActivity (lines) {
962
+ const sha256 = (t) => crypto.createHash('sha256').update(t, 'utf8').digest('hex')
963
+ let prev = null; let seq = null; let checked = 0; let unchained = 0
964
+ for (const text of lines) {
965
+ let e; try { e = JSON.parse(text) } catch { continue }
966
+ if (typeof e.seq !== 'number') { unchained++; continue } // entradas de antes de 0.94
967
+ if (seq !== null) {
968
+ if (e.seq !== seq + 1) return { ok: false, at: e.seq, why: `salta del #${seq} al #${e.seq}` }
969
+ if (e.prev !== prev) return { ok: false, at: e.seq, why: `el #${e.seq} no encadena con el #${seq}` }
970
+ }
971
+ seq = e.seq; prev = sha256(text); checked++
972
+ }
973
+ return { ok: true, checked, unchained, last: seq }
974
+ }
975
+
976
+ function cmdActivity (n = 30, { verify = false, exportar = false } = {}) {
977
+ const dir = profileDir()
978
+ const lines = readActivity(dir)
979
+ if (!lines.length) {
933
980
  console.log('Sin actividad registrada todavía (o el servicio es anterior a 0.1.10).'); return
934
981
  }
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 } })
982
+ if (exportar) { for (const l of lines) console.log(l); return }
983
+ if (verify) {
984
+ const r = verifyActivity(lines)
985
+ if (!r.ok) { console.error(`${R}CADENA ROTA${Z} en la entrada #${r.at}: ${r.why}`); process.exit(1) }
986
+ console.log(`Cadena íntegra: ${r.checked} entrada(s) encadenada(s)${r.last ? ` hasta la #${r.last}` : ''}.`)
987
+ if (r.unchained) console.log(` ${r.unchained} entrada(s) anteriores a 0.94, sin encadenar: no se pueden verificar.`)
988
+ return
989
+ }
939
990
  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' }
940
991
  for (const line of lines.slice(-n)) {
941
992
  try {
@@ -1610,6 +1661,8 @@ function help () {
1610
1661
  (--machine para volver a la clave de esta máquina). Editar
1611
1662
  atrest.json a mano NO vale: dejaría el perfil ilegible
1612
1663
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1664
+ activity --verify comprueba que la cadena está entera (nadie tocó el pasado)
1665
+ activity --export la vuelca entera en JSONL, para auditar o guardar fuera
1613
1666
  logs últimos logs del servicio
1614
1667
  version muestra la versión instalada
1615
1668
 
@@ -1664,7 +1717,9 @@ export async function runCtl (argv) {
1664
1717
  case 'secret': return cmdSecret(rest)
1665
1718
  case 'approval': return cmdApproval(rest)
1666
1719
  case 'atrest': return cmdAtrest(rest)
1667
- case 'activity': return cmdActivity(Number(rest[0]) || 30)
1720
+ case 'activity': return cmdActivity(Number(rest.find((a) => /^\d+$/.test(a))) || 30, {
1721
+ verify: rest.includes('--verify'), exportar: rest.includes('--export')
1722
+ })
1668
1723
  case 'logs': return cmdLogs()
1669
1724
  case 'version':
1670
1725
  case '--version':
package/src/vault.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * Toda la cripto es de `@dotrino/identity`. Este módulo solo orquesta.
12
12
  */
13
13
  import fs from 'node:fs'
14
+ import nodeCrypto from 'node:crypto'
14
15
  import path from 'node:path'
15
16
  import { Identity } from '@dotrino/identity/node'
16
17
  import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
@@ -367,18 +368,73 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
367
368
  // Y el modo, que hasta 0.89 se creaba 0664: legible por cualquier usuario.
368
369
  if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600)
369
370
  } catch (_) { /* no hay bitácora todavía */ }
371
+ /**
372
+ * LA BITÁCORA ES UNA CADENA, no una lista de líneas sueltas.
373
+ *
374
+ * Cifrarla (0.89) le dio confidencialidad y ninguna integridad: quien tenga la llave de
375
+ * la máquina puede reescribir una línea y nadie lo nota. Como evidencia no valía nada, y
376
+ * eso es justo lo que un auditor mira (`CUMPLIMIENTO.md` §2).
377
+ *
378
+ * Cada entrada lleva su `seq` y el **hash de la anterior**, así que tocar o quitar una
379
+ * rompe la cadena de ahí en adelante y se ve. Se encadena el TEXTO exacto de la línea,
380
+ * no un objeto re-serializado: así verificar no depende de que las claves salgan en el
381
+ * mismo orden.
382
+ *
383
+ * **Lo que esto NO resuelve, dicho para no venderlo de más:** quien tenga la máquina
384
+ * puede cortar el final de la cadena y quedarse con un prefijo válido. Eso no se cierra
385
+ * en local — pide anclar el último hash fuera (un TSA, otro aparato). Lo que sí cierra
386
+ * es reescribir el pasado sin dejar rastro, que es el caso que importaba.
387
+ */
388
+ const AUDIT_MAX_BYTES = 1024 * 1024
389
+ /** Cuántos archivos viejos se guardan. Es la RETENCIÓN, y va declarada. */
390
+ const AUDIT_KEEP = 5
391
+ const sha256 = (text) => nodeCrypto.createHash('sha256').update(text, 'utf8').digest('hex')
392
+
393
+ let auditSeq = 0
394
+ let auditPrev = null
395
+
396
+ /** Reconstruye el estado de la cadena leyendo la última entrada que haya. */
397
+ try {
398
+ const lines = fs.readFileSync(activityFile, 'utf8').split('\n').filter(Boolean)
399
+ for (let i = lines.length - 1; i >= 0; i--) {
400
+ try {
401
+ const text = activityAtRest.decrypt(lines[i])
402
+ const e = JSON.parse(text)
403
+ if (typeof e.seq === 'number') { auditSeq = e.seq; auditPrev = sha256(text); break }
404
+ } catch (_) { /* una línea vieja o ilegible: se sigue hacia atrás */ }
405
+ }
406
+ } catch (_) { /* no hay bitácora todavía */ }
407
+
408
+ /**
409
+ * ROTA ARCHIVANDO, NO TIRANDO. Antes se quedaba con la mitad del archivo y la otra mitad
410
+ * desaparecía sin que nada lo dijera: una bitácora que pierde historia en silencio es
411
+ * peor que no tenerla, porque parece completa. Ahora los viejos se corren a `.1`…`.N` y,
412
+ * cuando uno se cae del borde, **queda anotado que se cayó**.
413
+ */
414
+ const rotateAudit = () => {
415
+ let dropped = null
416
+ const older = `${activityFile}.${AUDIT_KEEP}`
417
+ try { if (fs.existsSync(older)) { fs.rmSync(older); dropped = `activity.log.${AUDIT_KEEP}` } } catch (_) {}
418
+ for (let i = AUDIT_KEEP - 1; i >= 1; i--) {
419
+ try { if (fs.existsSync(`${activityFile}.${i}`)) fs.renameSync(`${activityFile}.${i}`, `${activityFile}.${i + 1}`) } catch (_) {}
420
+ }
421
+ try { fs.renameSync(activityFile, `${activityFile}.1`) } catch (_) {}
422
+ return dropped
423
+ }
424
+
370
425
  const audit = (op, info = {}) => {
371
426
  try {
372
- const line = activityAtRest.encrypt(JSON.stringify({ ts: Date.now(), op, ...info }))
373
- fs.appendFileSync(activityFile, line + '\n', { mode: 0o600 })
427
+ const entry = { seq: ++auditSeq, prev: auditPrev, ts: Date.now(), op, ...info }
428
+ const text = JSON.stringify(entry)
429
+ fs.appendFileSync(activityFile, activityAtRest.encrypt(text) + '\n', { mode: 0o600 })
430
+ auditPrev = sha256(text)
374
431
  // El `mode` de `appendFileSync` solo aplica al CREAR: las bitácoras de antes de
375
432
  // 0.89 ya existen con 0664 y hay que estrecharlas a mano.
376
433
  try { if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600) } catch (_) {}
377
- // rotación simple: si pasa de ~1 MB, conservar la última mitad
378
- const st = fs.statSync(activityFile)
379
- if (st.size > 1024 * 1024) {
380
- const lines = fs.readFileSync(activityFile, 'utf8').split('\n')
381
- fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'), { mode: 0o600 })
434
+ if (fs.statSync(activityFile).size > AUDIT_MAX_BYTES) {
435
+ const dropped = rotateAudit()
436
+ // La primera entrada del archivo nuevo dice de dónde viene, y si se perdió algo.
437
+ audit('log.rotated', { keep: AUDIT_KEEP, ...(dropped ? { dropped } : {}) })
382
438
  }
383
439
  } catch (_) {}
384
440
  }