@dotrino/vaultd 0.93.0 → 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 +69 -14
- package/src/vault.js +94 -38
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.
|
|
@@ -573,7 +574,7 @@ async function cmdMembers () {
|
|
|
573
574
|
m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
|
|
574
575
|
marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
|
|
575
576
|
// Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
|
|
576
|
-
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio
|
|
577
|
+
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
577
578
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
578
579
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
579
580
|
}
|
|
@@ -820,11 +821,11 @@ function cmdAtrest (rest) {
|
|
|
820
821
|
// versión vieja dejó en claro, con el verificador de la contraseña dentro— meses
|
|
821
822
|
// después de que su migración terminara. Se mira el perfil Y la raíz, porque el canal
|
|
822
823
|
// local con la CLI vive arriba.
|
|
823
|
-
for (const [
|
|
824
|
+
for (const [label, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
|
|
824
825
|
const loose = plaintextFilesIn(d)
|
|
825
826
|
if (loose.length) {
|
|
826
827
|
console.log('')
|
|
827
|
-
console.log(`${R}En claro en ${
|
|
828
|
+
console.log(`${R}En claro en ${label}:${Z} ` + loose.join(', '))
|
|
828
829
|
console.log(' Si es un respaldo de una migración terminada, bórralo: no lo cifra nadie.')
|
|
829
830
|
}
|
|
830
831
|
}
|
|
@@ -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 {
|
|
@@ -958,7 +1009,7 @@ function cmdActivity (n = 30) {
|
|
|
958
1009
|
/**
|
|
959
1010
|
* `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
|
|
960
1011
|
* son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
|
|
961
|
-
*
|
|
1012
|
+
* donde el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
|
|
962
1013
|
*/
|
|
963
1014
|
function asPairs (args) {
|
|
964
1015
|
if (!args.length) return null
|
|
@@ -1395,7 +1446,7 @@ function reportProfiles (d) {
|
|
|
1395
1446
|
async function cmdProfile (rest) {
|
|
1396
1447
|
const [sub, ...rawArgs] = rest
|
|
1397
1448
|
// `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
|
|
1398
|
-
// los argumentos
|
|
1449
|
+
// los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
|
|
1399
1450
|
// cfg.json».
|
|
1400
1451
|
const kmsAt = rawArgs.indexOf('--kms')
|
|
1401
1452
|
const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
|
|
@@ -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'
|
|
@@ -119,7 +120,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
119
120
|
* Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
|
|
120
121
|
* no, espera a que un aparato con `approve` lo firme.
|
|
121
122
|
*
|
|
122
|
-
* Antes era al revés y vivía aquí: una
|
|
123
|
+
* Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
|
|
123
124
|
* un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
|
|
124
125
|
* omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
|
|
125
126
|
* el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
|
|
@@ -135,7 +136,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
135
136
|
}
|
|
136
137
|
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
137
138
|
// reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
|
|
138
|
-
// `passwords`), como cualquier otro permiso: aquí no hay una segunda
|
|
139
|
+
// `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
|
|
139
140
|
// aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
|
|
140
141
|
// además un emparejamiento paralelo al del ecosistema.
|
|
141
142
|
const passwordsFile = path.join(dir, 'passwords.json')
|
|
@@ -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
|
}
|
|
@@ -464,7 +520,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
464
520
|
*
|
|
465
521
|
* Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
|
|
466
522
|
* multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
|
|
467
|
-
* los rechazaban los diez mostradores. Ahora se compara contra la
|
|
523
|
+
* los rechazaban los diez mostradores. Ahora se compara contra la lista que dice el acta.
|
|
468
524
|
*
|
|
469
525
|
* Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
|
|
470
526
|
* mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
|
|
@@ -664,7 +720,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
664
720
|
* exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
|
|
665
721
|
*
|
|
666
722
|
* La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
|
|
667
|
-
* pidió, y contarle el perfil entero a quien no trae papel es
|
|
723
|
+
* pidió, y contarle el perfil entero a quien no trae papel es attach de más—. Al que ya no
|
|
668
724
|
* está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
|
|
669
725
|
* Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
|
|
670
726
|
* nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
|
|
@@ -710,7 +766,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
710
766
|
audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
|
|
711
767
|
return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, acta: record })
|
|
712
768
|
}
|
|
713
|
-
// El acta viaja con la
|
|
769
|
+
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
714
770
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
715
771
|
// Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
|
|
716
772
|
// que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
|
|
@@ -728,7 +784,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
728
784
|
}
|
|
729
785
|
// `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
|
|
730
786
|
// proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
|
|
731
|
-
//
|
|
787
|
+
// lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
|
|
732
788
|
const devices = await Promise.all(issued.map(async (x) => ({
|
|
733
789
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
734
790
|
})))
|
|
@@ -773,7 +829,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
773
829
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
774
830
|
// EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
|
|
775
831
|
// decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
|
|
776
|
-
// Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento:
|
|
832
|
+
// Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: attach
|
|
777
833
|
// `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
|
|
778
834
|
// QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
|
|
779
835
|
// Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
|
|
@@ -1059,7 +1115,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1059
1115
|
* NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
|
|
1060
1116
|
*
|
|
1061
1117
|
* El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
|
|
1062
|
-
* sin contenido: quien lo recibe pregunta por la
|
|
1118
|
+
* sin contenido: quien lo recibe pregunta por la lista, no la lee del aviso.
|
|
1063
1119
|
*/
|
|
1064
1120
|
async function notifyApprovers (pend, record) {
|
|
1065
1121
|
try {
|
|
@@ -1170,7 +1226,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1170
1226
|
cek: await passwordsKey(),
|
|
1171
1227
|
// UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
|
|
1172
1228
|
// Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
|
|
1173
|
-
// que haya una segunda
|
|
1229
|
+
// que haya una segunda lista que acordarse de tocar.
|
|
1174
1230
|
isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
1175
1231
|
encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
|
|
1176
1232
|
// EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
|
|
@@ -1348,11 +1404,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1348
1404
|
// Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
|
|
1349
1405
|
// eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
|
|
1350
1406
|
// `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
|
|
1351
|
-
// acta nueva (admitir un aparato con su permiso `sella`), su
|
|
1407
|
+
// acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
|
|
1352
1408
|
// tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
|
|
1353
1409
|
// quedaban con actas distintas y la del dueño sin enterarse de nada.
|
|
1354
1410
|
//
|
|
1355
|
-
// El acta es la
|
|
1411
|
+
// El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
|
|
1356
1412
|
// suma porque cubre a quien tiene cert y todavía no aparece ahí.
|
|
1357
1413
|
const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
|
|
1358
1414
|
const yo = identity.me?.publickey || null
|
|
@@ -1417,7 +1473,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1417
1473
|
// podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
|
|
1418
1474
|
// propósito: que uno falle no basta para leerlas.
|
|
1419
1475
|
//
|
|
1420
|
-
// Se sella la
|
|
1476
|
+
// Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
|
|
1421
1477
|
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
1422
1478
|
return {
|
|
1423
1479
|
enc: await identity.sealContent(JSON.stringify({
|
|
@@ -1438,7 +1494,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1438
1494
|
}
|
|
1439
1495
|
},
|
|
1440
1496
|
/**
|
|
1441
|
-
* PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la
|
|
1497
|
+
* PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la lista sale del ACTA
|
|
1442
1498
|
* (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
|
|
1443
1499
|
* exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
|
|
1444
1500
|
*/
|
|
@@ -1622,7 +1678,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1622
1678
|
// listar y borrar no la piden (ver `secretsStore.js`).
|
|
1623
1679
|
/**
|
|
1624
1680
|
* Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
|
|
1625
|
-
* (miembros del acta con ese `cn`). La bóveda NO entra en la
|
|
1681
|
+
* (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
|
|
1626
1682
|
* CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
|
|
1627
1683
|
* que este diseño quita.
|
|
1628
1684
|
*/
|
|
@@ -1630,7 +1686,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1630
1686
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1631
1687
|
if (!record) return []
|
|
1632
1688
|
// LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
|
|
1633
|
-
//
|
|
1689
|
+
// lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
|
|
1634
1690
|
//
|
|
1635
1691
|
// Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
|
|
1636
1692
|
// DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
|
|
@@ -1648,7 +1704,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1648
1704
|
* ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
|
|
1649
1705
|
* frase que se escribe en cualquier parte a una llave que no sale del aparato.
|
|
1650
1706
|
*
|
|
1651
|
-
* La bóveda NO entra en la
|
|
1707
|
+
* La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
|
|
1652
1708
|
* ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
|
|
1653
1709
|
* diseño quita. Hay un test que lo afirma.
|
|
1654
1710
|
*/
|
|
@@ -1841,7 +1897,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1841
1897
|
* hay quien la teclee.
|
|
1842
1898
|
*
|
|
1843
1899
|
* Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
|
|
1844
|
-
* vea: hasta ahora un servicio recién enrolado aparecía en la
|
|
1900
|
+
* vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
|
|
1845
1901
|
* otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
|
|
1846
1902
|
* mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
|
|
1847
1903
|
* (`dotrino-vault secret settle`, que sí pide la frase).
|
|
@@ -1860,7 +1916,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1860
1916
|
// «Ver» de la consola le fallaría sin explicar por qué.
|
|
1861
1917
|
// Un aparato que administra NO debe tener envoltura de los cajones con dueño
|
|
1862
1918
|
// (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
|
|
1863
|
-
// propósito y ponerlo en la
|
|
1919
|
+
// propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
|
|
1864
1920
|
const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
|
|
1865
1921
|
const drawers = m.cn
|
|
1866
1922
|
? [`ns:${m.cn}`, `dev:${m.pub}`]
|
|
@@ -1960,7 +2016,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1960
2016
|
* único que la frase guarda (§8.3).
|
|
1961
2017
|
*
|
|
1962
2018
|
* No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
|
|
1963
|
-
* tiene que seguir viéndose en la
|
|
2019
|
+
* tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
|
|
1964
2020
|
*/
|
|
1965
2021
|
/**
|
|
1966
2022
|
* PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
|
|
@@ -2236,8 +2292,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2236
2292
|
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
|
|
2237
2293
|
for (const owner of secrets.owners?.() || []) {
|
|
2238
2294
|
const before = new Set(secrets.recipientsIn(owner))
|
|
2239
|
-
// POR VISIBILIDAD, no una
|
|
2240
|
-
// administra. Con una sola
|
|
2295
|
+
// POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
|
|
2296
|
+
// administra. Con una sola lista se le daría también la de una privada con dueño, que
|
|
2241
2297
|
// es justo lo que no puede pasar.
|
|
2242
2298
|
const members = (esPublica) => recipientsOf(owner, { public: esPublica })
|
|
2243
2299
|
try {
|
|
@@ -2300,7 +2356,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2300
2356
|
if (!owed) return null
|
|
2301
2357
|
// QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
|
|
2302
2358
|
//
|
|
2303
|
-
// Antes cada llamador traía su propia
|
|
2359
|
+
// Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
|
|
2304
2360
|
// se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
|
|
2305
2361
|
// una variable envolvía para el servicio y dejaba al sellador con la generación vieja
|
|
2306
2362
|
// (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
|
|
@@ -2406,9 +2462,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2406
2462
|
* pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
|
|
2407
2463
|
* un valor o esconder la fila sería peor.
|
|
2408
2464
|
*/
|
|
2409
|
-
const withEnvelopes = (
|
|
2410
|
-
if (!caller) return
|
|
2411
|
-
const
|
|
2465
|
+
const withEnvelopes = (entries, kind, caller) => {
|
|
2466
|
+
if (!caller) return entries
|
|
2467
|
+
const attach = (owner, k) => {
|
|
2412
2468
|
if (!k.public) return k
|
|
2413
2469
|
const e = secrets.entryOf?.(owner, k.key)
|
|
2414
2470
|
const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
|
|
@@ -2416,10 +2472,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2416
2472
|
}
|
|
2417
2473
|
if (kind === 'ns') {
|
|
2418
2474
|
const out = {}
|
|
2419
|
-
for (const [ns, keys] of Object.entries(
|
|
2475
|
+
for (const [ns, keys] of Object.entries(entries)) out[ns] = keys.map((k) => attach(`ns:${ns}`, k))
|
|
2420
2476
|
return out
|
|
2421
2477
|
}
|
|
2422
|
-
return
|
|
2478
|
+
return entries.map((row) => ({ ...row, keys: row.keys.map((k) => attach(`dev:${row.pub}`, k)) }))
|
|
2423
2479
|
}
|
|
2424
2480
|
|
|
2425
2481
|
function listSecrets () {
|
|
@@ -2549,10 +2605,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2549
2605
|
stopPairing: desk.stopPairing,
|
|
2550
2606
|
listPending: desk.listPending,
|
|
2551
2607
|
// ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
|
|
2552
|
-
// (`unattended`), no una
|
|
2608
|
+
// (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
|
|
2553
2609
|
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
2554
2610
|
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
2555
|
-
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se
|
|
2611
|
+
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
2556
2612
|
// llave. Lo que no puede es listarla un aparato.
|
|
2557
2613
|
passwordDevices,
|
|
2558
2614
|
passwordsVault: () => passwords?.vault || null,
|
|
@@ -2599,7 +2655,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2599
2655
|
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
|
2600
2656
|
/**
|
|
2601
2657
|
* Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
|
|
2602
|
-
* normal es NO pasarlo: por defecto se usa la misma
|
|
2658
|
+
* normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
|
|
2603
2659
|
* cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
|
|
2604
2660
|
*
|
|
2605
2661
|
* Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
|
|
@@ -2644,7 +2700,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2644
2700
|
isMaster: () => identity.isMaster(),
|
|
2645
2701
|
setCaps: async (pub, caps) => {
|
|
2646
2702
|
const r = await identity.setCaps(pub, caps)
|
|
2647
|
-
// ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una
|
|
2703
|
+
// ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
|
|
2648
2704
|
// `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
|
|
2649
2705
|
// pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
|
|
2650
2706
|
// devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
|