@dotrino/vaultd 0.92.0 → 0.93.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 +5 -5
- package/src/daemon.js +4 -1
- package/src/ipc.js +31 -1
- package/src/node-globals.js +13 -1
- package/src/vault.js +58 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.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
|
@@ -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 [label, 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 ${label}:${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
|
}
|
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
|
@@ -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)
|
|
@@ -648,7 +664,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
648
664
|
* exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
|
|
649
665
|
*
|
|
650
666
|
* La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
|
|
651
|
-
* pidió, y contarle el perfil entero a quien no trae papel es
|
|
667
|
+
* pidió, y contarle el perfil entero a quien no trae papel es attach de más—. Al que ya no
|
|
652
668
|
* está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
|
|
653
669
|
* Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
|
|
654
670
|
* nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
|
|
@@ -757,7 +773,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
757
773
|
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
758
774
|
// EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
|
|
759
775
|
// decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
|
|
760
|
-
// Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento:
|
|
776
|
+
// Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: attach
|
|
761
777
|
// `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
|
|
762
778
|
// QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
|
|
763
779
|
// Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
|
|
@@ -1405,7 +1421,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
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(),
|
|
@@ -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.
|
|
@@ -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')
|
|
@@ -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
|
|
2395
|
-
const
|
|
2409
|
+
const withEnvelopes = (entries, kind, caller) => {
|
|
2410
|
+
if (!caller) return entries
|
|
2411
|
+
const attach = (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(entries)) out[ns] = keys.map((k) => attach(`ns:${ns}`, k))
|
|
2404
2420
|
return out
|
|
2405
2421
|
}
|
|
2406
|
-
return
|
|
2422
|
+
return entries.map((row) => ({ ...row, keys: row.keys.map((k) => attach(`dev:${row.pub}`, k)) }))
|
|
2407
2423
|
}
|
|
2408
2424
|
|
|
2409
2425
|
function listSecrets () {
|
|
@@ -2523,6 +2539,10 @@ 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,
|