@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.92.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 enClaroEn (d) {
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 [donde, d] of [['este perfil', dir], ['la raíz de la bóveda', vaultRoot]]) {
824
- const sueltos = enClaroEn(d)
825
- if (sueltos.length) {
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 ${donde}:${Z} ` + sueltos.join(', '))
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
- export default { ipcRead, ipcWrite }
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 }
@@ -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
- try { if (fs.existsSync(filePath)) data = JSON.parse(atRest.decrypt(fs.readFileSync(filePath, 'utf8'))) || {} } catch (_) {}
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
- try {
264
- const info = await identity.profileActa?.()
265
- const acta = info?.acta
266
- // CON EL PERFIL ABIERTO, y `isLocked()` es la pregunta correcta — no `masterLocked`.
267
- //
268
- // Se coló así: en la PRIMERA arrancada tras actualizar, la maestra todavía está guardada
269
- // en claro (se sella al abrir el perfil), o sea que `masterLocked` es `false` aunque el
270
- // perfil esté cerrado. Con ese guardián, la bóveda de producción admitió su llave de
271
- // comunicación y SELLÓ UN ACTA NUEVA (#76 → #77) estando cerrada — justo lo que la regla
272
- // prohíbe, y encima rotando de paso la llave de sellado sin que nadie lo pidiera.
273
- if (acta && !isLocked() && !identity.masterLocked) {
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
- const yaEsta = (acta.members || []).some((m) => m?.pub === pub)
276
- if (!yaEsta && info?.isMaster) {
277
- await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
278
- log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
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
- } catch (e) { log('[vault] could not put the communication key in the record:', e.message) }
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 crudo = fs.readFileSync(activityFile, 'utf8')
342
- const lineas = crudo.split('\n')
343
- if (lineas.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
344
- const convertidas = lineas.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
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', convertidas.join('\n'), { mode: 0o600 })
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: ${convertidas.filter(Boolean).length} line(s) sealed at rest`)
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 dar de más—. Al que ya no
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: dar
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: conSobres(listSecrets(), 'ns', caller), dev: conSobres(await listDeviceSecrets(), 'dev', caller),
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 verificarAutor(owner, key, sealed, caller)
1463
- await verificarDestinatarios(owner, sealed.wraps, !!isPublic)
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 verificarAutor(owner, it.key, it.sealed, caller)
1503
- await verificarDestinatarios(owner, it.sealed.wraps, !!it.public)
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 verificarAutor (owner, key, sealed, caller) {
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 verificarDestinatarios (owner, wraps, isPublic = false) {
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 conSobres = (lista, tipo, caller) => {
2394
- if (!caller) return lista
2395
- const dar = (owner, k) => {
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 (tipo === 'ns') {
2417
+ if (kind === 'ns') {
2402
2418
  const out = {}
2403
- for (const [ns, keys] of Object.entries(lista)) out[ns] = keys.map((k) => dar(`ns:${ns}`, k))
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 lista.map((row) => ({ ...row, keys: row.keys.map((k) => dar(`dev:${row.pub}`, k)) }))
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,