@dotrino/vaultd 0.92.0 → 0.93.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 +8 -8
- package/src/daemon.js +4 -1
- package/src/ipc.js +31 -1
- package/src/node-globals.js +13 -1
- package/src/vault.js +79 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.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
|
@@ -573,7 +573,7 @@ async function cmdMembers () {
|
|
|
573
573
|
m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
|
|
574
574
|
marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
|
|
575
575
|
// 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
|
|
576
|
+
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio where_
|
|
577
577
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
578
578
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
579
579
|
}
|
|
@@ -780,7 +780,7 @@ function profileDir () {
|
|
|
780
780
|
* antes de tener clave, `key.json` es una pública que dice de quién es la carpeta, y las
|
|
781
781
|
* preferencias no son de nadie. Todo lo demás en claro es un hallazgo.
|
|
782
782
|
*/
|
|
783
|
-
function
|
|
783
|
+
function plaintextFilesIn (d) {
|
|
784
784
|
const OK = new Set(['atrest.salt', 'atrest.machine', 'atrest.kek', 'atrest.json', 'vault.lock', 'prefs.json', 'key.json'])
|
|
785
785
|
let names = []
|
|
786
786
|
try { names = fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch (_) { return [] }
|
|
@@ -820,11 +820,11 @@ function cmdAtrest (rest) {
|
|
|
820
820
|
// versión vieja dejó en claro, con el verificador de la contraseña dentro— meses
|
|
821
821
|
// después de que su migración terminara. Se mira el perfil Y la raíz, porque el canal
|
|
822
822
|
// local con la CLI vive arriba.
|
|
823
|
-
for (const [
|
|
824
|
-
const
|
|
825
|
-
if (
|
|
823
|
+
for (const [where_, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
|
|
824
|
+
const loose = plaintextFilesIn(d)
|
|
825
|
+
if (loose.length) {
|
|
826
826
|
console.log('')
|
|
827
|
-
console.log(`${R}En claro en ${
|
|
827
|
+
console.log(`${R}En claro en ${where_}:${Z} ` + loose.join(', '))
|
|
828
828
|
console.log(' Si es un respaldo de una migración terminada, bórralo: no lo cifra nadie.')
|
|
829
829
|
}
|
|
830
830
|
}
|
|
@@ -958,7 +958,7 @@ function cmdActivity (n = 30) {
|
|
|
958
958
|
/**
|
|
959
959
|
* `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
|
|
960
960
|
* son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
|
|
961
|
-
*
|
|
961
|
+
* where_ el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
|
|
962
962
|
*/
|
|
963
963
|
function asPairs (args) {
|
|
964
964
|
if (!args.length) return null
|
|
@@ -1395,7 +1395,7 @@ function reportProfiles (d) {
|
|
|
1395
1395
|
async function cmdProfile (rest) {
|
|
1396
1396
|
const [sub, ...rawArgs] = rest
|
|
1397
1397
|
// `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
|
|
1398
|
-
// los argumentos
|
|
1398
|
+
// los argumentos loose, así que si no se quita acabaría llamándose «midevault --kms
|
|
1399
1399
|
// cfg.json».
|
|
1400
1400
|
const kmsAt = rawArgs.indexOf('--kms')
|
|
1401
1401
|
const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
|
package/src/daemon.js
CHANGED
|
@@ -20,7 +20,7 @@ import { takeLock } from '../lib/src/lock.js'
|
|
|
20
20
|
import { startVaultManager } from './manager.js'
|
|
21
21
|
import { dataDir } from './paths.js'
|
|
22
22
|
// EL CANAL LOCAL VA CIFRADO: por aquí pasan la contraseña, los valores y las invitaciones.
|
|
23
|
-
import { ipcRead, ipcWrite } from './ipc.js'
|
|
23
|
+
import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
|
|
24
24
|
import { parseInvite } from '../lib/src/invite.js'
|
|
25
25
|
import { watchBinary } from './selfupdate.js'
|
|
26
26
|
import { VERSION } from './version.js'
|
|
@@ -70,6 +70,9 @@ export async function runDaemon () {
|
|
|
70
70
|
const dir = dataDir()
|
|
71
71
|
const proxyUrl = process.env.PROXY_URL || 'wss://proxy.dotrino.com'
|
|
72
72
|
assertSingleInstance(dir)
|
|
73
|
+
// Lo que dejó escrito una versión anterior al canal cifrado. Se agota sola.
|
|
74
|
+
const sealedLeftovers = migrateIpcDir(dir)
|
|
75
|
+
if (sealedLeftovers) console.log('[vault] control channel: %d leftover file(s) sealed at rest', sealedLeftovers)
|
|
73
76
|
|
|
74
77
|
const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
|
|
75
78
|
// Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
|
package/src/ipc.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* Ojo con el dir: todos estos archivos cuelgan del directorio raíz, así que la clave sale
|
|
27
27
|
* de ahí (`path.dirname`). Un archivo de perfil tiene otro salt y NO se lee con esta.
|
|
28
28
|
*/
|
|
29
|
+
import fs from 'node:fs'
|
|
29
30
|
import path from 'node:path'
|
|
30
31
|
import { atRestFor } from './atrest.js'
|
|
31
32
|
import { readJson, writeJson } from './paths.js'
|
|
@@ -38,4 +39,33 @@ export const ipcRead = (file, fallback = null) => readJson(file, fallback, codec
|
|
|
38
39
|
/** Escribe un archivo del canal, cifrado y 0600 (atómico: tmp + rename). */
|
|
39
40
|
export const ipcWrite = (file, obj) => writeJson(file, obj, codecOf(file))
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
/**
|
|
43
|
+
* CONVIERTE LO QUE DEJÓ ESCRITO UNA VERSIÓN ANTERIOR.
|
|
44
|
+
*
|
|
45
|
+
* Cifrar al escribir no limpia el disco: un archivo del canal solo se reescribe cuando
|
|
46
|
+
* alguien vuelve a pedir esa operación, y hay respuestas que se quedan meses. `approve.json`
|
|
47
|
+
* —con el id del último aparato aprobado— seguía en claro días después de la actualización,
|
|
48
|
+
* esperando a que hubiera otra aprobación.
|
|
49
|
+
*
|
|
50
|
+
* Se pasa una vez al arrancar el daemon y se agota sola. Un archivo que no se pueda leer se
|
|
51
|
+
* deja como está: no es de nadie borrar lo que no entiende.
|
|
52
|
+
*/
|
|
53
|
+
export function migrateIpcDir (dir) {
|
|
54
|
+
let names = []
|
|
55
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name) } catch (_) { return 0 }
|
|
56
|
+
let n = 0
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (!name.endsWith('.json') || name.startsWith('atrest.')) continue
|
|
59
|
+
const file = path.join(dir, name)
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(file, 'utf8')
|
|
62
|
+
if (!raw.trim() || raw.startsWith('DOTRINO-ATREST-v1.')) continue
|
|
63
|
+
const obj = JSON.parse(raw)
|
|
64
|
+
ipcWrite(file, obj)
|
|
65
|
+
n++
|
|
66
|
+
} catch (_) { /* no se entiende: se deja */ }
|
|
67
|
+
}
|
|
68
|
+
return n
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default { ipcRead, ipcWrite, migrateIpcDir }
|
package/src/node-globals.js
CHANGED
|
@@ -23,11 +23,23 @@ import { atRestFor } from './atrest.js'
|
|
|
23
23
|
export function fileLocalStorage (filePath) {
|
|
24
24
|
const atRest = atRestFor(path.dirname(filePath))
|
|
25
25
|
let data = {}
|
|
26
|
-
|
|
26
|
+
let wasPlaintext = false
|
|
27
|
+
try {
|
|
28
|
+
if (fs.existsSync(filePath)) {
|
|
29
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
30
|
+
wasPlaintext = !!raw.trim() && !raw.startsWith('DOTRINO-ATREST-v1.')
|
|
31
|
+
data = JSON.parse(atRest.decrypt(raw)) || {}
|
|
32
|
+
}
|
|
33
|
+
} catch (_) {}
|
|
27
34
|
const flush = () => {
|
|
28
35
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
29
36
|
fs.writeFileSync(filePath, atRest.encrypt(JSON.stringify(data)), { mode: 0o600 })
|
|
30
37
|
}
|
|
38
|
+
// CIFRAR AL ESCRIBIR NO CONVIERTE LO YA ESCRITO, y aquí eso importaba de verdad: el par
|
|
39
|
+
// de transporte se acuña UNA vez y no se vuelve a tocar, así que el archivo que dejó una
|
|
40
|
+
// versión anterior —con su privada en claro— se quedaba así para siempre. Se convierte de
|
|
41
|
+
// una vez al abrirlo. Se agota solo: la siguiente vez ya está cifrado.
|
|
42
|
+
if (wasPlaintext) { try { flush() } catch (_) {} }
|
|
31
43
|
return {
|
|
32
44
|
getItem: (k) => (k in data ? data[k] : null),
|
|
33
45
|
setItem: (k, v) => { data[k] = String(v); flush() },
|
package/src/vault.js
CHANGED
|
@@ -119,7 +119,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
119
119
|
* Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
|
|
120
120
|
* no, espera a que un aparato con `approve` lo firme.
|
|
121
121
|
*
|
|
122
|
-
* Antes era al revés y vivía aquí: una
|
|
122
|
+
* Antes era al revés y vivía aquí: una list_ local de «estos SÍ piden permiso», así que
|
|
123
123
|
* un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
|
|
124
124
|
* omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
|
|
125
125
|
* el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
|
|
@@ -135,7 +135,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
135
135
|
}
|
|
136
136
|
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
137
137
|
// 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
|
|
138
|
+
// `passwords`), como cualquier otro permiso: aquí no hay una segunda list_ de
|
|
139
139
|
// aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
|
|
140
140
|
// además un emparejamiento paralelo al del ecosistema.
|
|
141
141
|
const passwordsFile = path.join(dir, 'passwords.json')
|
|
@@ -260,25 +260,41 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
260
260
|
* `identify` se repliega a la maestra y lo dice. Por eso una bóveda que se actualiza
|
|
261
261
|
* tiene que abrirse UNA vez, y a partir de ahí ya puede vivir cerrada.
|
|
262
262
|
*/
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
263
|
+
/**
|
|
264
|
+
* METER LA LLAVE DE COMUNICACIÓN EN EL ACTA, si el perfil está abierto.
|
|
265
|
+
*
|
|
266
|
+
* `isLocked()` es la pregunta correcta — no `masterLocked`. Se coló así: en la PRIMERA
|
|
267
|
+
* arrancada tras actualizar, la maestra todavía está guardada en claro (se sella al abrir
|
|
268
|
+
* el perfil), o sea que `masterLocked` es `false` aunque el perfil esté cerrado. Con ese
|
|
269
|
+
* guardián, la bóveda de producción admitió su llave de comunicación y SELLÓ UN ACTA
|
|
270
|
+
* NUEVA (#76 → #77) estando cerrada — justo lo que la regla prohíbe, y encima rotando de
|
|
271
|
+
* paso la llave de sellado sin que nadie lo pidiera.
|
|
272
|
+
*
|
|
273
|
+
* SE LLAMA TAMBIÉN AL ABRIR, y esa es la mitad que faltaba. Antes solo corría al arrancar
|
|
274
|
+
* el servicio, y **un perfil con contraseña arranca cerrado siempre**: nunca pasaba por
|
|
275
|
+
* aquí, así que jamás llegaba a tener su llave. O sea que los perfiles que MÁS la
|
|
276
|
+
* necesitan —los protegidos, los que no pueden firmar con la maestra estando cerrados—
|
|
277
|
+
* eran precisamente los únicos que se quedaban sin ella, y seguían identificándose con la
|
|
278
|
+
* maestra o quedándose mudos. Se vio el 2026-09-02: de dos perfiles, el que tenía
|
|
279
|
+
* contraseña era el que no tenía `commkey.json`.
|
|
280
|
+
*/
|
|
281
|
+
async function ensureCommKeyInActa () {
|
|
282
|
+
try {
|
|
283
|
+
const info = await identity.profileActa?.()
|
|
284
|
+
const acta = info?.acta
|
|
285
|
+
if (!acta || isLocked() || identity.masterLocked) return false
|
|
274
286
|
const pub = await commKey.ensure()
|
|
275
|
-
|
|
276
|
-
if (!
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
287
|
+
if ((acta.members || []).some((m) => m?.pub === pub)) return false
|
|
288
|
+
if (!info?.isMaster) return false
|
|
289
|
+
await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
|
|
290
|
+
log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
|
|
291
|
+
return true
|
|
292
|
+
} catch (e) {
|
|
293
|
+
log('[vault] could not put the communication key in the record:', e.message)
|
|
294
|
+
return false
|
|
280
295
|
}
|
|
281
|
-
}
|
|
296
|
+
}
|
|
297
|
+
await ensureCommKeyInActa()
|
|
282
298
|
|
|
283
299
|
const { client } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
|
|
284
300
|
|
|
@@ -338,15 +354,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
338
354
|
// cierto también para lo que ya estaba escrito, no solo para lo que se escriba desde
|
|
339
355
|
// ahora. Se agota sola — cuando no queda ninguna línea en claro, no vuelve a tocar nada.
|
|
340
356
|
try {
|
|
341
|
-
const
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
const
|
|
357
|
+
const raw = fs.readFileSync(activityFile, 'utf8')
|
|
358
|
+
const lines = raw.split('\n')
|
|
359
|
+
if (lines.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
|
|
360
|
+
const sealedLines = lines.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
|
|
345
361
|
// Escribir aparte y renombrar: a medio convertir, la bitácora sería ilegible entera.
|
|
346
|
-
fs.writeFileSync(activityFile + '.tmp',
|
|
362
|
+
fs.writeFileSync(activityFile + '.tmp', sealedLines.join('\n'), { mode: 0o600 })
|
|
347
363
|
fs.renameSync(activityFile + '.tmp', activityFile)
|
|
348
364
|
// Sin `%d`: el `log` de la bóveda no formatea, así que el placeholder salía literal.
|
|
349
|
-
log(`[vault] activity log: ${
|
|
365
|
+
log(`[vault] activity log: ${sealedLines.filter(Boolean).length} line(s) sealed at rest`)
|
|
350
366
|
}
|
|
351
367
|
// Y el modo, que hasta 0.89 se creaba 0664: legible por cualquier usuario.
|
|
352
368
|
if ((fs.statSync(activityFile).mode & 0o077) !== 0) fs.chmodSync(activityFile, 0o600)
|
|
@@ -448,7 +464,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
448
464
|
*
|
|
449
465
|
* Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
|
|
450
466
|
* multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
|
|
451
|
-
* los rechazaban los diez mostradores. Ahora se compara contra la
|
|
467
|
+
* los rechazaban los diez mostradores. Ahora se compara contra la list_ que dice el acta.
|
|
452
468
|
*
|
|
453
469
|
* Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
|
|
454
470
|
* mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
|
|
@@ -694,7 +710,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
694
710
|
audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
|
|
695
711
|
return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, acta: record })
|
|
696
712
|
}
|
|
697
|
-
// El acta viaja con la
|
|
713
|
+
// El acta viaja con la list_: así cada dispositivo se entera de los cambios de
|
|
698
714
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
699
715
|
// Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
|
|
700
716
|
// que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
|
|
@@ -712,7 +728,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
712
728
|
}
|
|
713
729
|
// `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
|
|
714
730
|
// proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
|
|
715
|
-
//
|
|
731
|
+
// list_ tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
|
|
716
732
|
const devices = await Promise.all(issued.map(async (x) => ({
|
|
717
733
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
718
734
|
})))
|
|
@@ -1043,7 +1059,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1043
1059
|
* NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
|
|
1044
1060
|
*
|
|
1045
1061
|
* El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
|
|
1046
|
-
* sin contenido: quien lo recibe pregunta por la
|
|
1062
|
+
* sin contenido: quien lo recibe pregunta por la list_, no la lee del aviso.
|
|
1047
1063
|
*/
|
|
1048
1064
|
async function notifyApprovers (pend, record) {
|
|
1049
1065
|
try {
|
|
@@ -1154,7 +1170,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1154
1170
|
cek: await passwordsKey(),
|
|
1155
1171
|
// UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
|
|
1156
1172
|
// Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
|
|
1157
|
-
// que haya una segunda
|
|
1173
|
+
// que haya una segunda list_ que acordarse de tocar.
|
|
1158
1174
|
isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
1159
1175
|
encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
|
|
1160
1176
|
// EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
|
|
@@ -1332,11 +1348,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1332
1348
|
// Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
|
|
1333
1349
|
// eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
|
|
1334
1350
|
// `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
|
|
1335
|
-
// acta nueva (admitir un aparato con su permiso `sella`), su
|
|
1351
|
+
// acta nueva (admitir un aparato con su permiso `sella`), su list_ de emitidas no
|
|
1336
1352
|
// tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
|
|
1337
1353
|
// quedaban con actas distintas y la del dueño sin enterarse de nada.
|
|
1338
1354
|
//
|
|
1339
|
-
// El acta es la
|
|
1355
|
+
// El acta es la list_ de quién es del perfil: esa es la list_ correcta. `issued` se
|
|
1340
1356
|
// suma porque cubre a quien tiene cert y todavía no aparece ahí.
|
|
1341
1357
|
const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
|
|
1342
1358
|
const yo = identity.me?.publickey || null
|
|
@@ -1401,11 +1417,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1401
1417
|
// podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
|
|
1402
1418
|
// propósito: que uno falle no basta para leerlas.
|
|
1403
1419
|
//
|
|
1404
|
-
// Se sella la
|
|
1420
|
+
// Se sella la list_ ENTERA, no solo los valores: el proxy tampoco tiene por qué
|
|
1405
1421
|
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
1406
1422
|
return {
|
|
1407
1423
|
enc: await identity.sealContent(JSON.stringify({
|
|
1408
|
-
ns:
|
|
1424
|
+
ns: withEnvelopes(listSecrets(), 'ns', caller), dev: withEnvelopes(await listDeviceSecrets(), 'dev', caller),
|
|
1409
1425
|
// Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
|
|
1410
1426
|
// enseñarlo donde se administra, no solo en el log del servicio.
|
|
1411
1427
|
incomplete: await incompleteMembers(),
|
|
@@ -1422,7 +1438,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1422
1438
|
}
|
|
1423
1439
|
},
|
|
1424
1440
|
/**
|
|
1425
|
-
* PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la
|
|
1441
|
+
* PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la list_ sale del ACTA
|
|
1426
1442
|
* (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
|
|
1427
1443
|
* exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
|
|
1428
1444
|
*/
|
|
@@ -1459,8 +1475,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1459
1475
|
// PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
|
|
1460
1476
|
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
1461
1477
|
// marca solo decide si se entrega sin aprobación.
|
|
1462
|
-
await
|
|
1463
|
-
await
|
|
1478
|
+
await checkAuthor(owner, key, sealed, caller)
|
|
1479
|
+
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
1464
1480
|
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
1465
1481
|
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
1466
1482
|
await settleDebts(owner)
|
|
@@ -1499,8 +1515,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1499
1515
|
const keys = []
|
|
1500
1516
|
for (const it of items) {
|
|
1501
1517
|
if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
|
|
1502
|
-
await
|
|
1503
|
-
await
|
|
1518
|
+
await checkAuthor(owner, it.key, it.sealed, caller)
|
|
1519
|
+
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
1504
1520
|
}
|
|
1505
1521
|
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
1506
1522
|
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
@@ -1606,7 +1622,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1606
1622
|
// listar y borrar no la piden (ver `secretsStore.js`).
|
|
1607
1623
|
/**
|
|
1608
1624
|
* Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
|
|
1609
|
-
* (miembros del acta con ese `cn`). La bóveda NO entra en la
|
|
1625
|
+
* (miembros del acta con ese `cn`). La bóveda NO entra en la list_ — envolverle la
|
|
1610
1626
|
* CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
|
|
1611
1627
|
* que este diseño quita.
|
|
1612
1628
|
*/
|
|
@@ -1614,7 +1630,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1614
1630
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1615
1631
|
if (!record) return []
|
|
1616
1632
|
// LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
|
|
1617
|
-
//
|
|
1633
|
+
// list_ si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
|
|
1618
1634
|
//
|
|
1619
1635
|
// Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
|
|
1620
1636
|
// DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
|
|
@@ -1632,7 +1648,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1632
1648
|
* ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
|
|
1633
1649
|
* frase que se escribe en cualquier parte a una llave que no sale del aparato.
|
|
1634
1650
|
*
|
|
1635
|
-
* La bóveda NO entra en la
|
|
1651
|
+
* La bóveda NO entra en la list_, ni aquí ni en `nsMembers`: envolverle la llave a
|
|
1636
1652
|
* ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
|
|
1637
1653
|
* diseño quita. Hay un test que lo afirma.
|
|
1638
1654
|
*/
|
|
@@ -1825,7 +1841,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1825
1841
|
* hay quien la teclee.
|
|
1826
1842
|
*
|
|
1827
1843
|
* Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
|
|
1828
|
-
* vea: hasta ahora un servicio recién enrolado aparecía en la
|
|
1844
|
+
* vea: hasta ahora un servicio recién enrolado aparecía en la list_ como cualquier
|
|
1829
1845
|
* otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
|
|
1830
1846
|
* mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
|
|
1831
1847
|
* (`dotrino-vault secret settle`, que sí pide la frase).
|
|
@@ -1844,7 +1860,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1844
1860
|
// «Ver» de la consola le fallaría sin explicar por qué.
|
|
1845
1861
|
// Un aparato que administra NO debe tener envoltura de los cajones con dueño
|
|
1846
1862
|
// (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
|
|
1847
|
-
// propósito y ponerlo en la
|
|
1863
|
+
// propósito y ponerlo en la list_ sería pedir que se «arregle» lo correcto.
|
|
1848
1864
|
const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
|
|
1849
1865
|
const drawers = m.cn
|
|
1850
1866
|
? [`ns:${m.cn}`, `dev:${m.pub}`]
|
|
@@ -1944,7 +1960,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1944
1960
|
* único que la frase guarda (§8.3).
|
|
1945
1961
|
*
|
|
1946
1962
|
* No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
|
|
1947
|
-
* tiene que seguir viéndose en la
|
|
1963
|
+
* tiene que seguir viéndose en la list_ en vez de tumbar la operación entera.
|
|
1948
1964
|
*/
|
|
1949
1965
|
/**
|
|
1950
1966
|
* PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
|
|
@@ -2077,7 +2093,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2077
2093
|
* relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
|
|
2078
2094
|
* las dos comprobaciones lo note.
|
|
2079
2095
|
*/
|
|
2080
|
-
async function
|
|
2096
|
+
async function checkAuthor (owner, key, sealed, caller) {
|
|
2081
2097
|
const a = sealed?.author
|
|
2082
2098
|
if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
|
|
2083
2099
|
throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
|
|
@@ -2126,7 +2142,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2126
2142
|
*
|
|
2127
2143
|
* Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
|
|
2128
2144
|
*/
|
|
2129
|
-
async function
|
|
2145
|
+
async function checkRecipients (owner, wraps, isPublic = false) {
|
|
2130
2146
|
if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
|
|
2131
2147
|
const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
|
|
2132
2148
|
if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
|
|
@@ -2220,8 +2236,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2220
2236
|
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
|
|
2221
2237
|
for (const owner of secrets.owners?.() || []) {
|
|
2222
2238
|
const before = new Set(secrets.recipientsIn(owner))
|
|
2223
|
-
// POR VISIBILIDAD, no una
|
|
2224
|
-
// administra. Con una sola
|
|
2239
|
+
// POR VISIBILIDAD, no una list_ fija: una pública lleva además la envoltura de quien
|
|
2240
|
+
// administra. Con una sola list_ se le daría también la de una privada con dueño, que
|
|
2225
2241
|
// es justo lo que no puede pasar.
|
|
2226
2242
|
const members = (esPublica) => recipientsOf(owner, { public: esPublica })
|
|
2227
2243
|
try {
|
|
@@ -2284,7 +2300,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2284
2300
|
if (!owed) return null
|
|
2285
2301
|
// QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
|
|
2286
2302
|
//
|
|
2287
|
-
// Antes cada llamador traía su propia
|
|
2303
|
+
// Antes cada llamador traía su propia list_ (`nsMembers`, o el miembro suelto), y todas
|
|
2288
2304
|
// se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
|
|
2289
2305
|
// una variable envolvía para el servicio y dejaba al sellador con la generación vieja
|
|
2290
2306
|
// (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
|
|
@@ -2390,20 +2406,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2390
2406
|
* pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
|
|
2391
2407
|
* un valor o esconder la fila sería peor.
|
|
2392
2408
|
*/
|
|
2393
|
-
const
|
|
2394
|
-
if (!caller) return
|
|
2409
|
+
const withEnvelopes = (list_, kind, caller) => {
|
|
2410
|
+
if (!caller) return list_
|
|
2395
2411
|
const dar = (owner, k) => {
|
|
2396
2412
|
if (!k.public) return k
|
|
2397
2413
|
const e = secrets.entryOf?.(owner, k.key)
|
|
2398
2414
|
const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
|
|
2399
2415
|
return e?.e && wrap ? { ...k, e: e.e, wrap } : k
|
|
2400
2416
|
}
|
|
2401
|
-
if (
|
|
2417
|
+
if (kind === 'ns') {
|
|
2402
2418
|
const out = {}
|
|
2403
|
-
for (const [ns, keys] of Object.entries(
|
|
2419
|
+
for (const [ns, keys] of Object.entries(list_)) out[ns] = keys.map((k) => dar(`ns:${ns}`, k))
|
|
2404
2420
|
return out
|
|
2405
2421
|
}
|
|
2406
|
-
return
|
|
2422
|
+
return list_.map((row) => ({ ...row, keys: row.keys.map((k) => dar(`dev:${row.pub}`, k)) }))
|
|
2407
2423
|
}
|
|
2408
2424
|
|
|
2409
2425
|
function listSecrets () {
|
|
@@ -2523,16 +2539,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2523
2539
|
async takeMasterKey () {
|
|
2524
2540
|
const r = await identity.reloadMasterKey?.()
|
|
2525
2541
|
if (r?.locked === false) { try { await identity.sealMasterKey?.() } catch (_) {} }
|
|
2542
|
+
// ABRIR ES EL ÚNICO MOMENTO en que un perfil con contraseña está abierto, así que es
|
|
2543
|
+
// aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
|
|
2544
|
+
// Si falla, no se arrastra: abrir el perfil no puede depender de esto.
|
|
2545
|
+
if (r?.locked === false) { try { await ensureCommKeyInActa() } catch (_) {} }
|
|
2526
2546
|
return { locked: r?.locked !== false }
|
|
2527
2547
|
},
|
|
2528
2548
|
startPairing: desk.startPairing,
|
|
2529
2549
|
stopPairing: desk.stopPairing,
|
|
2530
2550
|
listPending: desk.listPending,
|
|
2531
2551
|
// ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
|
|
2532
|
-
// (`unattended`), no una
|
|
2552
|
+
// (`unattended`), no una list_ de esta máquina — por eso ya no hay `setApproval` ni
|
|
2533
2553
|
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
2534
2554
|
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
2535
|
-
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se
|
|
2555
|
+
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se list_: es donde está la
|
|
2536
2556
|
// llave. Lo que no puede es listarla un aparato.
|
|
2537
2557
|
passwordDevices,
|
|
2538
2558
|
passwordsVault: () => passwords?.vault || null,
|
|
@@ -2579,7 +2599,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2579
2599
|
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
|
2580
2600
|
/**
|
|
2581
2601
|
* Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
|
|
2582
|
-
* normal es NO pasarlo: por defecto se usa la misma
|
|
2602
|
+
* normal es NO pasarlo: por defecto se usa la misma list_ de destinatarios que
|
|
2583
2603
|
* cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
|
|
2584
2604
|
*
|
|
2585
2605
|
* Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
|
|
@@ -2624,7 +2644,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2624
2644
|
isMaster: () => identity.isMaster(),
|
|
2625
2645
|
setCaps: async (pub, caps) => {
|
|
2626
2646
|
const r = await identity.setCaps(pub, caps)
|
|
2627
|
-
// ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una
|
|
2647
|
+
// ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una list_ CERRADA de permisos y
|
|
2628
2648
|
// `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
|
|
2629
2649
|
// pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
|
|
2630
2650
|
// devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
|