@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.
- package/package.json +1 -1
- package/src/ctl.js +64 -9
- package/src/vault.js +63 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
}
|