@dotrino/vaultd 0.93.1 → 0.94.1
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/package.json +1 -1
- package/src/ctl.js +66 -9
- package/src/vault.js +71 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.1",
|
|
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,68 @@ 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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
+
// Sin `logSeq` es de antes de que la cadena existiera: no se puede verificar, y se
|
|
967
|
+
// dice cuántas hay en vez de callarlo.
|
|
968
|
+
if (typeof e.logSeq !== 'number') { unchained++; continue }
|
|
969
|
+
if (seq !== null) {
|
|
970
|
+
if (e.logSeq !== seq + 1) return { ok: false, at: e.logSeq, why: `salta del #${seq} al #${e.logSeq}` }
|
|
971
|
+
if (e.logPrev !== prev) return { ok: false, at: e.logSeq, why: `el #${e.logSeq} no encadena con el #${seq}` }
|
|
972
|
+
}
|
|
973
|
+
seq = e.logSeq; prev = sha256(text); checked++
|
|
974
|
+
}
|
|
975
|
+
return { ok: true, checked, unchained, last: seq }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function cmdActivity (n = 30, { verify = false, exportar = false } = {}) {
|
|
979
|
+
const dir = profileDir()
|
|
980
|
+
const lines = readActivity(dir)
|
|
981
|
+
if (!lines.length) {
|
|
933
982
|
console.log('Sin actividad registrada todavía (o el servicio es anterior a 0.1.10).'); return
|
|
934
983
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
984
|
+
if (exportar) { for (const l of lines) console.log(l); return }
|
|
985
|
+
if (verify) {
|
|
986
|
+
const r = verifyActivity(lines)
|
|
987
|
+
if (!r.ok) { console.error(`${R}CADENA ROTA${Z} en la entrada #${r.at}: ${r.why}`); process.exit(1) }
|
|
988
|
+
console.log(`Cadena íntegra: ${r.checked} entrada(s) encadenada(s)${r.last ? ` hasta la #${r.last}` : ''}.`)
|
|
989
|
+
if (r.unchained) console.log(` ${r.unchained} entrada(s) anteriores a 0.94, sin encadenar: no se pueden verificar.`)
|
|
990
|
+
return
|
|
991
|
+
}
|
|
939
992
|
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
993
|
for (const line of lines.slice(-n)) {
|
|
941
994
|
try {
|
|
@@ -1610,6 +1663,8 @@ function help () {
|
|
|
1610
1663
|
(--machine para volver a la clave de esta máquina). Editar
|
|
1611
1664
|
atrest.json a mano NO vale: dejaría el perfil ilegible
|
|
1612
1665
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
1666
|
+
activity --verify comprueba que la cadena está entera (nadie tocó el pasado)
|
|
1667
|
+
activity --export la vuelca entera en JSONL, para auditar o guardar fuera
|
|
1613
1668
|
logs últimos logs del servicio
|
|
1614
1669
|
version muestra la versión instalada
|
|
1615
1670
|
|
|
@@ -1664,7 +1719,9 @@ export async function runCtl (argv) {
|
|
|
1664
1719
|
case 'secret': return cmdSecret(rest)
|
|
1665
1720
|
case 'approval': return cmdApproval(rest)
|
|
1666
1721
|
case 'atrest': return cmdAtrest(rest)
|
|
1667
|
-
case 'activity': return cmdActivity(Number(rest
|
|
1722
|
+
case 'activity': return cmdActivity(Number(rest.find((a) => /^\d+$/.test(a))) || 30, {
|
|
1723
|
+
verify: rest.includes('--verify'), exportar: rest.includes('--export')
|
|
1724
|
+
})
|
|
1668
1725
|
case 'logs': return cmdLogs()
|
|
1669
1726
|
case 'version':
|
|
1670
1727
|
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,81 @@ 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.logSeq === 'number') { auditSeq = e.logSeq; 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
|
-
|
|
373
|
-
|
|
427
|
+
// LOS CAMPOS DE LA CADENA VAN AL FINAL Y CON NOMBRE PROPIO, y las dos cosas por el
|
|
428
|
+
// mismo motivo: que quien llama no pueda pisarlos.
|
|
429
|
+
//
|
|
430
|
+
// Estaban delante y se llamaban `seq`/`prev`, así que `...info` los sobrescribía. Y
|
|
431
|
+
// no era hipotético: `renew` ya mandaba un `seq` —el del ACTA, que no tiene nada que
|
|
432
|
+
// ver— y dejaba dos entradas seguidas numeradas igual. Lo cazó el propio verificador
|
|
433
|
+
// contra la bitácora de producción a los diez minutos de desplegarlo, que es
|
|
434
|
+
// exactamente para lo que sirve.
|
|
435
|
+
const entry = { ...info, ts: Date.now(), op, logSeq: ++auditSeq, logPrev: auditPrev }
|
|
436
|
+
const text = JSON.stringify(entry)
|
|
437
|
+
fs.appendFileSync(activityFile, activityAtRest.encrypt(text) + '\n', { mode: 0o600 })
|
|
438
|
+
auditPrev = sha256(text)
|
|
374
439
|
// El `mode` de `appendFileSync` solo aplica al CREAR: las bitácoras de antes de
|
|
375
440
|
// 0.89 ya existen con 0664 y hay que estrecharlas a mano.
|
|
376
441
|
try { if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600) } catch (_) {}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
fs.writeFileSync(activityFile, lines.slice(Math.floor(lines.length / 2)).join('\n'), { mode: 0o600 })
|
|
442
|
+
if (fs.statSync(activityFile).size > AUDIT_MAX_BYTES) {
|
|
443
|
+
const dropped = rotateAudit()
|
|
444
|
+
// La primera entrada del archivo nuevo dice de dónde viene, y si se perdió algo.
|
|
445
|
+
audit('log.rotated', { keep: AUDIT_KEEP, ...(dropped ? { dropped } : {}) })
|
|
382
446
|
}
|
|
383
447
|
} catch (_) {}
|
|
384
448
|
}
|